pidriver 0.3.0__tar.gz → 0.4.2__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.
- {pidriver-0.3.0 → pidriver-0.4.2}/.gitignore +1 -0
- pidriver-0.4.2/.sc/last-chat-settings.json +4 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/PKG-INFO +85 -3
- {pidriver-0.3.0 → pidriver-0.4.2}/README.md +81 -1
- {pidriver-0.3.0 → pidriver-0.4.2}/pyproject.toml +7 -1
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/__init__.py +26 -1
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/_install.py +10 -2
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/config.py +1 -1
- pidriver-0.4.2/src/pidriver/instances.py +241 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/manager.py +7 -3
- pidriver-0.4.2/src/pidriver/repository.py +220 -0
- pidriver-0.4.2/src/pidriver/store.py +269 -0
- pidriver-0.4.2/src/pidriver/supervisor.py +424 -0
- pidriver-0.4.2/src/pidriver/web.py +749 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_install.py +32 -8
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_manager.py +14 -0
- pidriver-0.4.2/tests/test_repository.py +240 -0
- pidriver-0.4.2/tests/test_store.py +33 -0
- pidriver-0.4.2/tests/test_supervisor.py +113 -0
- pidriver-0.4.2/tests/test_web.py +85 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/LICENSE +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/examples/basic_prompt.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/_transport.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/assets/pi_login.mjs +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/auth.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/client.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/errors.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/events.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/interaction.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/models.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/py.typed +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/session.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/staging.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/usage.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/workspace.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/conftest.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/README.md +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/_capture_driver.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/agent_end.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/agent_start.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_start_assistant.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_start_user.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.text_delta.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/ready.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/response_prompt_ack.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_end.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_start.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_update.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/turn_end.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/turn_start.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/payloads/get_state.response.json +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_auth.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_config.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_event_summary.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_events.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_fixtures_replay.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_install_resolve.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_models.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_replay_session.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_session.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_staging.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_transport.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_ui_methods.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_usage.py +0 -0
- {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pidriver
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
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
|
|
@@ -20,6 +20,8 @@ Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
|
20
20
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
21
21
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
22
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
23
|
+
Provides-Extra: web
|
|
24
|
+
Requires-Dist: uvicorn>=0.30; extra == 'web'
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
# pidriver
|
|
@@ -152,7 +154,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
|
|
|
152
154
|
| `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
|
|
153
155
|
| `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
|
|
154
156
|
| `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
|
|
155
|
-
| `pi_version` | `"0.
|
|
157
|
+
| `pi_version` | `"0.87.1"` | Pinned pi version to install (overridable). |
|
|
156
158
|
| `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
|
|
157
159
|
|
|
158
160
|
**Model / provider**
|
|
@@ -240,6 +242,9 @@ You don't have to install a CLI yourself. Upstream `pi` is an npm package, and p
|
|
|
240
242
|
When the transport starts, it resolves the binary in order — explicit `binary` path →
|
|
241
243
|
`install_dir/bin/pi` → `PATH`. If none resolves and `auto_install=True` (default), it runs
|
|
242
244
|
`npm install -g --prefix <install_dir> <npm_package>@<pi_version>` and runs `<install_dir>/bin/pi`.
|
|
245
|
+
An existing managed copy is upgraded when its version differs from `pi_version` and
|
|
246
|
+
`auto_install=True`. A `pi` already found on `PATH` (or set via an absolute `binary`)
|
|
247
|
+
is used as-is; update that installation separately if needed.
|
|
243
248
|
|
|
244
249
|
```python
|
|
245
250
|
from pidriver import ensure_binary, install_pi, ensure_extensions # also exported for explicit use
|
|
@@ -522,6 +527,48 @@ handler = chain(
|
|
|
522
527
|
)
|
|
523
528
|
```
|
|
524
529
|
|
|
530
|
+
## Tasks in remote Git repositories
|
|
531
|
+
|
|
532
|
+
Use `start_repository_task` with a trusted Git URL selected by the host. It clones the
|
|
533
|
+
repository, creates a per-job worktree, and starts pi in that worktree. No changes are
|
|
534
|
+
published until the host explicitly calls `merge()`; `discard()` deletes the local clone
|
|
535
|
+
without changing the remote. For SSH, pass a private-key path; otherwise Git uses the host's credentials.
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
from pathlib import Path
|
|
539
|
+
from pidriver import PiClient, PiConfig, start_repository_task
|
|
540
|
+
|
|
541
|
+
client = PiClient(PiConfig())
|
|
542
|
+
session, workspace = await start_repository_task(
|
|
543
|
+
client, "Fix the failing tests", "git+ssh://git@github.com/org/project.git",
|
|
544
|
+
root=Path("/srv/pi-jobs"), job_id="task-123",
|
|
545
|
+
ssh_key=Path("/srv/keys/project_ed25519"),
|
|
546
|
+
known_hosts=Path("/srv/keys/known_hosts"), # optional: pin host keys to this file
|
|
547
|
+
)
|
|
548
|
+
# Drain the session's events, then close it before finalizing the workspace.
|
|
549
|
+
async for event in session:
|
|
550
|
+
...
|
|
551
|
+
await session.aclose()
|
|
552
|
+
|
|
553
|
+
print(workspace.diff()) # review before deciding
|
|
554
|
+
state = workspace.to_state() # save for approval after a host restart if needed
|
|
555
|
+
# workspace = GitRepositoryWorkspace.from_state(state)
|
|
556
|
+
workspace.merge() # commits, merges and pushes to the cloned default branch
|
|
557
|
+
workspace.cleanup() # remove the local clone after successful push
|
|
558
|
+
# Or: workspace.discard() # no changes to origin
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
If the remote branch has moved, the push is rejected with `MergeConflict`; the local
|
|
562
|
+
workspace remains available for inspection. Never accept arbitrary repository URLs from
|
|
563
|
+
untrusted requests: the caller must enforce an allowlist. Empty repositories and a
|
|
564
|
+
second job with the same ID are rejected. SSH also accepts standard `ssh://` and
|
|
565
|
+
`git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
|
|
566
|
+
later pushes; the key must still exist then. SSH uses strict host-key checking and
|
|
567
|
+
non-interactive authentication. Pass `known_hosts=Path(...)` to pin host keys to that
|
|
568
|
+
file alone (`UserKnownHostsFile=<file>`, `GlobalKnownHostsFile=/dev/null`; the path is
|
|
569
|
+
saved by `to_state()` too) — useful in containers where ssh's own `~/.ssh` isn't yours
|
|
570
|
+
to manage; otherwise configure ssh's default `known_hosts` on the host in advance.
|
|
571
|
+
|
|
525
572
|
## Workspace jail
|
|
526
573
|
|
|
527
574
|
Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
|
|
@@ -574,6 +621,41 @@ await login.submit_code(info["session"], code) # accepts a bare code or a ca
|
|
|
574
621
|
locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
|
|
575
622
|
works too) — both exported for hosts building their own extension/auth tooling.
|
|
576
623
|
|
|
624
|
+
## Web instance service
|
|
625
|
+
|
|
626
|
+
The web subsystem is an optional layer over the same driver core. `pidriver` stays
|
|
627
|
+
dependency-free by default; install the web extra only for the ASGI server:
|
|
628
|
+
|
|
629
|
+
```sh
|
|
630
|
+
uv pip install -e ".[web]"
|
|
631
|
+
uv run pidriver-web --store pidriver.sqlite3 \
|
|
632
|
+
--projects-root /srv/projects --allow-project acme
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Core pieces:
|
|
636
|
+
|
|
637
|
+
- `PiInstanceSupervisor` drains every `PiSession` in one background task, persists event history,
|
|
638
|
+
broadcasts events to subscribers, and exposes `prompt` / `respond` / `cancel` / `stop`.
|
|
639
|
+
- `PiInstanceStore` stores snapshots and event logs in SQLite. On service restart, previously live
|
|
640
|
+
subprocesses are marked `stale` because they cannot be reattached.
|
|
641
|
+
- `PiWebApp` is a small ASGI app with JSON endpoints and SSE streaming.
|
|
642
|
+
|
|
643
|
+
Useful routes:
|
|
644
|
+
|
|
645
|
+
| Route | Purpose |
|
|
646
|
+
| --- | --- |
|
|
647
|
+
| `GET /` | Minimal dashboard for launching and watching instances. |
|
|
648
|
+
| `GET /api/projects` | List project-jail candidates. |
|
|
649
|
+
| `GET /api/models` | List models visible to this isolated pi config. |
|
|
650
|
+
| `POST /api/instances` | Start an instance from `{prompt, project, model, approval_policy}`. |
|
|
651
|
+
| `GET /api/instances` / `GET /api/instances/{id}` | List or inspect snapshots. |
|
|
652
|
+
| `GET /api/instances/{id}/events` | Read persisted event history. |
|
|
653
|
+
| `GET /api/instances/{id}/events/stream` | SSE stream for live events. |
|
|
654
|
+
| `POST /api/instances/{id}/prompt` | Send a follow-up prompt. |
|
|
655
|
+
| `POST /api/instances/{id}/respond` | Answer a pending interaction. |
|
|
656
|
+
| `POST /api/instances/{id}/cancel` / `POST /api/instances/{id}/stop` | Interrupt or terminate. |
|
|
657
|
+
| `GET /api/auth/providers` / `POST /api/auth/{provider}/start` | OAuth helper endpoints. |
|
|
658
|
+
|
|
577
659
|
## Development
|
|
578
660
|
|
|
579
661
|
```sh
|
|
@@ -128,7 +128,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
|
|
|
128
128
|
| `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
|
|
129
129
|
| `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
|
|
130
130
|
| `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
|
|
131
|
-
| `pi_version` | `"0.
|
|
131
|
+
| `pi_version` | `"0.87.1"` | Pinned pi version to install (overridable). |
|
|
132
132
|
| `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
|
|
133
133
|
|
|
134
134
|
**Model / provider**
|
|
@@ -216,6 +216,9 @@ You don't have to install a CLI yourself. Upstream `pi` is an npm package, and p
|
|
|
216
216
|
When the transport starts, it resolves the binary in order — explicit `binary` path →
|
|
217
217
|
`install_dir/bin/pi` → `PATH`. If none resolves and `auto_install=True` (default), it runs
|
|
218
218
|
`npm install -g --prefix <install_dir> <npm_package>@<pi_version>` and runs `<install_dir>/bin/pi`.
|
|
219
|
+
An existing managed copy is upgraded when its version differs from `pi_version` and
|
|
220
|
+
`auto_install=True`. A `pi` already found on `PATH` (or set via an absolute `binary`)
|
|
221
|
+
is used as-is; update that installation separately if needed.
|
|
219
222
|
|
|
220
223
|
```python
|
|
221
224
|
from pidriver import ensure_binary, install_pi, ensure_extensions # also exported for explicit use
|
|
@@ -498,6 +501,48 @@ handler = chain(
|
|
|
498
501
|
)
|
|
499
502
|
```
|
|
500
503
|
|
|
504
|
+
## Tasks in remote Git repositories
|
|
505
|
+
|
|
506
|
+
Use `start_repository_task` with a trusted Git URL selected by the host. It clones the
|
|
507
|
+
repository, creates a per-job worktree, and starts pi in that worktree. No changes are
|
|
508
|
+
published until the host explicitly calls `merge()`; `discard()` deletes the local clone
|
|
509
|
+
without changing the remote. For SSH, pass a private-key path; otherwise Git uses the host's credentials.
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
from pathlib import Path
|
|
513
|
+
from pidriver import PiClient, PiConfig, start_repository_task
|
|
514
|
+
|
|
515
|
+
client = PiClient(PiConfig())
|
|
516
|
+
session, workspace = await start_repository_task(
|
|
517
|
+
client, "Fix the failing tests", "git+ssh://git@github.com/org/project.git",
|
|
518
|
+
root=Path("/srv/pi-jobs"), job_id="task-123",
|
|
519
|
+
ssh_key=Path("/srv/keys/project_ed25519"),
|
|
520
|
+
known_hosts=Path("/srv/keys/known_hosts"), # optional: pin host keys to this file
|
|
521
|
+
)
|
|
522
|
+
# Drain the session's events, then close it before finalizing the workspace.
|
|
523
|
+
async for event in session:
|
|
524
|
+
...
|
|
525
|
+
await session.aclose()
|
|
526
|
+
|
|
527
|
+
print(workspace.diff()) # review before deciding
|
|
528
|
+
state = workspace.to_state() # save for approval after a host restart if needed
|
|
529
|
+
# workspace = GitRepositoryWorkspace.from_state(state)
|
|
530
|
+
workspace.merge() # commits, merges and pushes to the cloned default branch
|
|
531
|
+
workspace.cleanup() # remove the local clone after successful push
|
|
532
|
+
# Or: workspace.discard() # no changes to origin
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
If the remote branch has moved, the push is rejected with `MergeConflict`; the local
|
|
536
|
+
workspace remains available for inspection. Never accept arbitrary repository URLs from
|
|
537
|
+
untrusted requests: the caller must enforce an allowlist. Empty repositories and a
|
|
538
|
+
second job with the same ID are rejected. SSH also accepts standard `ssh://` and
|
|
539
|
+
`git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
|
|
540
|
+
later pushes; the key must still exist then. SSH uses strict host-key checking and
|
|
541
|
+
non-interactive authentication. Pass `known_hosts=Path(...)` to pin host keys to that
|
|
542
|
+
file alone (`UserKnownHostsFile=<file>`, `GlobalKnownHostsFile=/dev/null`; the path is
|
|
543
|
+
saved by `to_state()` too) — useful in containers where ssh's own `~/.ssh` isn't yours
|
|
544
|
+
to manage; otherwise configure ssh's default `known_hosts` on the host in advance.
|
|
545
|
+
|
|
501
546
|
## Workspace jail
|
|
502
547
|
|
|
503
548
|
Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
|
|
@@ -550,6 +595,41 @@ await login.submit_code(info["session"], code) # accepts a bare code or a ca
|
|
|
550
595
|
locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
|
|
551
596
|
works too) — both exported for hosts building their own extension/auth tooling.
|
|
552
597
|
|
|
598
|
+
## Web instance service
|
|
599
|
+
|
|
600
|
+
The web subsystem is an optional layer over the same driver core. `pidriver` stays
|
|
601
|
+
dependency-free by default; install the web extra only for the ASGI server:
|
|
602
|
+
|
|
603
|
+
```sh
|
|
604
|
+
uv pip install -e ".[web]"
|
|
605
|
+
uv run pidriver-web --store pidriver.sqlite3 \
|
|
606
|
+
--projects-root /srv/projects --allow-project acme
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Core pieces:
|
|
610
|
+
|
|
611
|
+
- `PiInstanceSupervisor` drains every `PiSession` in one background task, persists event history,
|
|
612
|
+
broadcasts events to subscribers, and exposes `prompt` / `respond` / `cancel` / `stop`.
|
|
613
|
+
- `PiInstanceStore` stores snapshots and event logs in SQLite. On service restart, previously live
|
|
614
|
+
subprocesses are marked `stale` because they cannot be reattached.
|
|
615
|
+
- `PiWebApp` is a small ASGI app with JSON endpoints and SSE streaming.
|
|
616
|
+
|
|
617
|
+
Useful routes:
|
|
618
|
+
|
|
619
|
+
| Route | Purpose |
|
|
620
|
+
| --- | --- |
|
|
621
|
+
| `GET /` | Minimal dashboard for launching and watching instances. |
|
|
622
|
+
| `GET /api/projects` | List project-jail candidates. |
|
|
623
|
+
| `GET /api/models` | List models visible to this isolated pi config. |
|
|
624
|
+
| `POST /api/instances` | Start an instance from `{prompt, project, model, approval_policy}`. |
|
|
625
|
+
| `GET /api/instances` / `GET /api/instances/{id}` | List or inspect snapshots. |
|
|
626
|
+
| `GET /api/instances/{id}/events` | Read persisted event history. |
|
|
627
|
+
| `GET /api/instances/{id}/events/stream` | SSE stream for live events. |
|
|
628
|
+
| `POST /api/instances/{id}/prompt` | Send a follow-up prompt. |
|
|
629
|
+
| `POST /api/instances/{id}/respond` | Answer a pending interaction. |
|
|
630
|
+
| `POST /api/instances/{id}/cancel` / `POST /api/instances/{id}/stop` | Interrupt or terminate. |
|
|
631
|
+
| `GET /api/auth/providers` / `POST /api/auth/{provider}/start` | OAuth helper endpoints. |
|
|
632
|
+
|
|
553
633
|
## Development
|
|
554
634
|
|
|
555
635
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pidriver"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.2"
|
|
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" }
|
|
@@ -28,6 +28,12 @@ dev = [
|
|
|
28
28
|
"mypy>=1.11",
|
|
29
29
|
"ruff>=0.6",
|
|
30
30
|
]
|
|
31
|
+
web = [
|
|
32
|
+
"uvicorn>=0.30",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
pidriver-web = "pidriver.web:main"
|
|
31
37
|
|
|
32
38
|
[build-system]
|
|
33
39
|
requires = ["hatchling"]
|
|
@@ -88,6 +88,15 @@ from .events import (
|
|
|
88
88
|
is_freetext_option,
|
|
89
89
|
parse_event,
|
|
90
90
|
)
|
|
91
|
+
from .instances import (
|
|
92
|
+
ApprovalPolicy,
|
|
93
|
+
EventEnvelope,
|
|
94
|
+
InstanceStatus,
|
|
95
|
+
PiInstanceSnapshot,
|
|
96
|
+
PiInstanceSpec,
|
|
97
|
+
event_to_dict,
|
|
98
|
+
interaction_to_dict,
|
|
99
|
+
)
|
|
91
100
|
from .interaction import (
|
|
92
101
|
ASK_FIRST_SYSTEM_PROMPT,
|
|
93
102
|
AskHost,
|
|
@@ -100,6 +109,7 @@ from .interaction import (
|
|
|
100
109
|
)
|
|
101
110
|
from .manager import ManagedSession, PiSessionManager
|
|
102
111
|
from .models import ModelInfo, list_models, parse_models
|
|
112
|
+
from .repository import GitRepositoryWorkspace, stage_repository, start_repository_task
|
|
103
113
|
from .session import PiSession
|
|
104
114
|
from .staging import (
|
|
105
115
|
DEFAULT_COPY_EXCLUDES,
|
|
@@ -108,10 +118,12 @@ from .staging import (
|
|
|
108
118
|
StagedWorkspace,
|
|
109
119
|
stage_workspace,
|
|
110
120
|
)
|
|
121
|
+
from .store import PiInstanceStore
|
|
122
|
+
from .supervisor import PiInstanceSupervisor
|
|
111
123
|
from .usage import UsageTotals
|
|
112
124
|
from .workspace import ProjectDir, list_candidate_projects, list_projects, resolve_project
|
|
113
125
|
|
|
114
|
-
__version__ = "0.
|
|
126
|
+
__version__ = "0.4.2"
|
|
115
127
|
|
|
116
128
|
__all__ = [
|
|
117
129
|
"__version__",
|
|
@@ -165,6 +177,16 @@ __all__ = [
|
|
|
165
177
|
"chain",
|
|
166
178
|
"response_to_wire",
|
|
167
179
|
"ASK_FIRST_SYSTEM_PROMPT",
|
|
180
|
+
# instances / supervisor
|
|
181
|
+
"ApprovalPolicy",
|
|
182
|
+
"InstanceStatus",
|
|
183
|
+
"PiInstanceSpec",
|
|
184
|
+
"PiInstanceSnapshot",
|
|
185
|
+
"EventEnvelope",
|
|
186
|
+
"event_to_dict",
|
|
187
|
+
"interaction_to_dict",
|
|
188
|
+
"PiInstanceStore",
|
|
189
|
+
"PiInstanceSupervisor",
|
|
168
190
|
# workspace
|
|
169
191
|
"resolve_project",
|
|
170
192
|
"list_projects",
|
|
@@ -172,7 +194,10 @@ __all__ = [
|
|
|
172
194
|
"ProjectDir",
|
|
173
195
|
# staging
|
|
174
196
|
"stage_workspace",
|
|
197
|
+
"stage_repository",
|
|
198
|
+
"start_repository_task",
|
|
175
199
|
"StagedWorkspace",
|
|
200
|
+
"GitRepositoryWorkspace",
|
|
176
201
|
"MergeResult",
|
|
177
202
|
"DEFAULT_COPY_EXCLUDES",
|
|
178
203
|
"DEFAULT_MAX_COPY_BYTES",
|
|
@@ -131,7 +131,8 @@ def ensure_binary(config: PiConfig, *, runner: Runner = subprocess.run) -> str:
|
|
|
131
131
|
|
|
132
132
|
Resolution order:
|
|
133
133
|
1. ``config.binary`` — an absolute path that exists, or a name found on PATH.
|
|
134
|
-
2. ``<install_dir>/bin/pi`` — a
|
|
134
|
+
2. ``<install_dir>/bin/pi`` — a managed copy; if auto-installing, upgrade
|
|
135
|
+
it when its version differs from ``config.pi_version``.
|
|
135
136
|
3. if still missing and ``auto_install`` with ``install_dir`` set → npm-install it.
|
|
136
137
|
Raises :class:`PiNotFoundError` if it can't be resolved (and install isn't allowed).
|
|
137
138
|
"""
|
|
@@ -147,7 +148,14 @@ def ensure_binary(config: PiConfig, *, runner: Runner = subprocess.run) -> str:
|
|
|
147
148
|
if config.install_dir is not None:
|
|
148
149
|
managed = managed_binary_path(config.install_dir)
|
|
149
150
|
if managed.exists():
|
|
150
|
-
|
|
151
|
+
if not config.auto_install:
|
|
152
|
+
return str(managed)
|
|
153
|
+
try:
|
|
154
|
+
_verify_version(managed, config.pi_version, runner=runner)
|
|
155
|
+
except PiInstallError:
|
|
156
|
+
pass
|
|
157
|
+
else:
|
|
158
|
+
return str(managed)
|
|
151
159
|
|
|
152
160
|
if config.auto_install and config.install_dir is not None:
|
|
153
161
|
path = install_pi(
|
|
@@ -110,7 +110,7 @@ class PiConfig:
|
|
|
110
110
|
install_dir: Path | None = None # where to install/look for a managed pi
|
|
111
111
|
auto_install: bool = True # npm-install pi into install_dir if missing
|
|
112
112
|
npm_package: str = "@earendil-works/pi-coding-agent" # the npm package providing `pi`
|
|
113
|
-
pi_version: str = "0.
|
|
113
|
+
pi_version: str = "0.87.1" # known-good pin; overridable
|
|
114
114
|
|
|
115
115
|
# --- extensions / skills / packages ------------------------------------
|
|
116
116
|
# User-chosen add-ons installed into the (isolated) pi config dir via
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Typed instance records for service/web hosts.
|
|
2
|
+
|
|
3
|
+
The low-level driver exposes a live :class:`pidriver.session.PiSession`; web and
|
|
4
|
+
service hosts need a stable, serializable record around that subprocess: launch
|
|
5
|
+
inputs, lifecycle status, pending interaction, usage, and event history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .events import (
|
|
16
|
+
AgentEnd,
|
|
17
|
+
AgentStart,
|
|
18
|
+
Channel,
|
|
19
|
+
Error,
|
|
20
|
+
Event,
|
|
21
|
+
InteractionRequest,
|
|
22
|
+
MessageComplete,
|
|
23
|
+
MessageDelta,
|
|
24
|
+
ToolEnd,
|
|
25
|
+
ToolStart,
|
|
26
|
+
ToolUpdate,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from .usage import UsageTotals
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ApprovalPolicy",
|
|
33
|
+
"InstanceStatus",
|
|
34
|
+
"PiInstanceSpec",
|
|
35
|
+
"PiInstanceSnapshot",
|
|
36
|
+
"EventEnvelope",
|
|
37
|
+
"event_to_dict",
|
|
38
|
+
"interaction_to_dict",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApprovalPolicy(StrEnum):
|
|
43
|
+
"""Host-side policy for answering permission prompts."""
|
|
44
|
+
|
|
45
|
+
ASK = "ask"
|
|
46
|
+
AUTO_APPROVE = "auto_approve"
|
|
47
|
+
AUTO_APPROVE_ALWAYS = "auto_approve_always"
|
|
48
|
+
DENY_ALL = "deny_all"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InstanceStatus(StrEnum):
|
|
52
|
+
"""Durable lifecycle state for a managed pi instance."""
|
|
53
|
+
|
|
54
|
+
QUEUED = "queued"
|
|
55
|
+
STARTING = "starting"
|
|
56
|
+
RUNNING = "running"
|
|
57
|
+
WAITING = "waiting"
|
|
58
|
+
COMPLETED = "completed"
|
|
59
|
+
FAILED = "failed"
|
|
60
|
+
CANCELLED = "cancelled"
|
|
61
|
+
STOPPED = "stopped"
|
|
62
|
+
STALE = "stale"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def terminal(self) -> bool:
|
|
66
|
+
return self in {
|
|
67
|
+
InstanceStatus.COMPLETED,
|
|
68
|
+
InstanceStatus.FAILED,
|
|
69
|
+
InstanceStatus.CANCELLED,
|
|
70
|
+
InstanceStatus.STOPPED,
|
|
71
|
+
InstanceStatus.STALE,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class PiInstanceSpec:
|
|
77
|
+
"""Launch request for one managed pi instance.
|
|
78
|
+
|
|
79
|
+
``project`` is the safe public selector. ``cwd`` is an internal/testing escape
|
|
80
|
+
hatch; web-facing callers should resolve projects through the supervisor jail.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
prompt: str
|
|
84
|
+
project: str | None = None
|
|
85
|
+
cwd: Path | None = None
|
|
86
|
+
provider: str | None = None
|
|
87
|
+
model: str | None = None
|
|
88
|
+
thinking: str | None = None
|
|
89
|
+
approval_policy: ApprovalPolicy = ApprovalPolicy.ASK
|
|
90
|
+
session_id: str | None = None
|
|
91
|
+
resume: str | None = None
|
|
92
|
+
send_initial: bool = True
|
|
93
|
+
system_prompt: str | None = None
|
|
94
|
+
append_system_prompt: tuple[str, ...] = ()
|
|
95
|
+
|
|
96
|
+
def as_dict(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"prompt": self.prompt,
|
|
99
|
+
"project": self.project,
|
|
100
|
+
"cwd": str(self.cwd) if self.cwd is not None else None,
|
|
101
|
+
"provider": self.provider,
|
|
102
|
+
"model": self.model,
|
|
103
|
+
"thinking": self.thinking,
|
|
104
|
+
"approval_policy": self.approval_policy.value,
|
|
105
|
+
"session_id": self.session_id,
|
|
106
|
+
"resume": self.resume,
|
|
107
|
+
"send_initial": self.send_initial,
|
|
108
|
+
"system_prompt": self.system_prompt,
|
|
109
|
+
"append_system_prompt": list(self.append_system_prompt),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class PiInstanceSnapshot:
|
|
115
|
+
"""Serializable public state for an instance."""
|
|
116
|
+
|
|
117
|
+
instance_id: str
|
|
118
|
+
status: InstanceStatus
|
|
119
|
+
prompt: str
|
|
120
|
+
project: str | None = None
|
|
121
|
+
cwd: str | None = None
|
|
122
|
+
provider: str | None = None
|
|
123
|
+
model: str | None = None
|
|
124
|
+
thinking: str | None = None
|
|
125
|
+
approval_policy: ApprovalPolicy = ApprovalPolicy.ASK
|
|
126
|
+
pi_session_id: str = ""
|
|
127
|
+
pid: int | None = None
|
|
128
|
+
returncode: int | None = None
|
|
129
|
+
pending: dict[str, Any] | None = None
|
|
130
|
+
usage: UsageTotals = field(default_factory=UsageTotals)
|
|
131
|
+
final_text: str = ""
|
|
132
|
+
error: str = ""
|
|
133
|
+
created_at: float = 0.0
|
|
134
|
+
updated_at: float = 0.0
|
|
135
|
+
started_at: float | None = None
|
|
136
|
+
ended_at: float | None = None
|
|
137
|
+
|
|
138
|
+
def as_dict(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"instance_id": self.instance_id,
|
|
141
|
+
"status": self.status.value,
|
|
142
|
+
"prompt": self.prompt,
|
|
143
|
+
"project": self.project,
|
|
144
|
+
"cwd": self.cwd,
|
|
145
|
+
"provider": self.provider,
|
|
146
|
+
"model": self.model,
|
|
147
|
+
"thinking": self.thinking,
|
|
148
|
+
"approval_policy": self.approval_policy.value,
|
|
149
|
+
"pi_session_id": self.pi_session_id,
|
|
150
|
+
"pid": self.pid,
|
|
151
|
+
"returncode": self.returncode,
|
|
152
|
+
"pending": self.pending,
|
|
153
|
+
"usage": self.usage.as_dict(),
|
|
154
|
+
"final_text": self.final_text,
|
|
155
|
+
"error": self.error,
|
|
156
|
+
"created_at": self.created_at,
|
|
157
|
+
"updated_at": self.updated_at,
|
|
158
|
+
"started_at": self.started_at,
|
|
159
|
+
"ended_at": self.ended_at,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class EventEnvelope:
|
|
165
|
+
"""One persisted/broadcast event for an instance."""
|
|
166
|
+
|
|
167
|
+
sequence: int
|
|
168
|
+
instance_id: str
|
|
169
|
+
timestamp: float
|
|
170
|
+
event: dict[str, Any]
|
|
171
|
+
|
|
172
|
+
def as_dict(self) -> dict[str, Any]:
|
|
173
|
+
return {
|
|
174
|
+
"sequence": self.sequence,
|
|
175
|
+
"instance_id": self.instance_id,
|
|
176
|
+
"timestamp": self.timestamp,
|
|
177
|
+
"event": self.event,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def interaction_to_dict(request: InteractionRequest) -> dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
"type": request.type,
|
|
184
|
+
"request_id": request.request_id,
|
|
185
|
+
"method": request.method,
|
|
186
|
+
"kind": request.kind.value,
|
|
187
|
+
"prompt": request.prompt,
|
|
188
|
+
"options": list(request.options),
|
|
189
|
+
"tool_call_id": request.tool_call_id,
|
|
190
|
+
"default": request.default,
|
|
191
|
+
"raw": request.raw,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def event_to_dict(event: Event) -> dict[str, Any]:
|
|
196
|
+
"""Convert a typed event to a JSON-friendly dict."""
|
|
197
|
+
|
|
198
|
+
common: dict[str, Any] = {"type": event.type, "raw": event.raw}
|
|
199
|
+
match event:
|
|
200
|
+
case AgentStart(session_id=session_id, model=model, cwd=cwd):
|
|
201
|
+
return {**common, "session_id": session_id, "model": model, "cwd": cwd}
|
|
202
|
+
case MessageDelta(text=text, channel=channel):
|
|
203
|
+
return {**common, "text": text, "channel": _channel_value(channel)}
|
|
204
|
+
case MessageComplete(text=text, channel=channel):
|
|
205
|
+
return {**common, "text": text, "channel": _channel_value(channel)}
|
|
206
|
+
case ToolStart(tool_call_id=tool_call_id, name=name, arguments=arguments):
|
|
207
|
+
return {
|
|
208
|
+
**common,
|
|
209
|
+
"tool_call_id": tool_call_id,
|
|
210
|
+
"name": name,
|
|
211
|
+
"arguments": arguments,
|
|
212
|
+
}
|
|
213
|
+
case ToolUpdate(tool_call_id=tool_call_id, name=name, output=output):
|
|
214
|
+
return {
|
|
215
|
+
**common,
|
|
216
|
+
"tool_call_id": tool_call_id,
|
|
217
|
+
"name": name,
|
|
218
|
+
"output": output,
|
|
219
|
+
}
|
|
220
|
+
case ToolEnd(tool_call_id=tool_call_id, name=name, result=result, is_error=is_error):
|
|
221
|
+
return {
|
|
222
|
+
**common,
|
|
223
|
+
"tool_call_id": tool_call_id,
|
|
224
|
+
"name": name,
|
|
225
|
+
"result": result,
|
|
226
|
+
"is_error": is_error,
|
|
227
|
+
}
|
|
228
|
+
case Usage(totals=totals):
|
|
229
|
+
return {**common, "totals": totals.as_dict()}
|
|
230
|
+
case AgentEnd(reason=reason, final_text=final_text):
|
|
231
|
+
return {**common, "reason": reason, "final_text": final_text}
|
|
232
|
+
case Error(message=message, code=code, fatal=fatal):
|
|
233
|
+
return {**common, "message": message, "code": code, "fatal": fatal}
|
|
234
|
+
case InteractionRequest() as request:
|
|
235
|
+
return interaction_to_dict(request)
|
|
236
|
+
case _:
|
|
237
|
+
return common
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _channel_value(channel: Channel | str) -> str:
|
|
241
|
+
return channel.value if isinstance(channel, Channel) else str(channel)
|
|
@@ -107,9 +107,13 @@ class PiSessionManager:
|
|
|
107
107
|
async with self._lock:
|
|
108
108
|
self._guard_capacity()
|
|
109
109
|
session = await self._factory(*args, **kwargs)
|
|
110
|
-
|
|
111
|
-
self.
|
|
112
|
-
|
|
110
|
+
try:
|
|
111
|
+
async with self._lock:
|
|
112
|
+
self._guard_capacity()
|
|
113
|
+
self._sessions[session.session_id] = session
|
|
114
|
+
except Exception:
|
|
115
|
+
await _safe_aclose(session)
|
|
116
|
+
raise
|
|
113
117
|
self._ensure_reaper()
|
|
114
118
|
return session
|
|
115
119
|
|