pidriver 0.2.0__tar.gz → 0.4.1__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.2.0 → pidriver-0.4.1}/.gitignore +1 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/PKG-INFO +81 -3
- {pidriver-0.2.0 → pidriver-0.4.1}/README.md +77 -1
- {pidriver-0.2.0 → pidriver-0.4.1}/pyproject.toml +7 -1
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/__init__.py +43 -1
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/_install.py +10 -2
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/config.py +1 -1
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/errors.py +39 -0
- pidriver-0.4.1/src/pidriver/instances.py +241 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/manager.py +7 -3
- pidriver-0.4.1/src/pidriver/repository.py +177 -0
- pidriver-0.4.1/src/pidriver/staging.py +634 -0
- pidriver-0.4.1/src/pidriver/store.py +269 -0
- pidriver-0.4.1/src/pidriver/supervisor.py +424 -0
- pidriver-0.4.1/src/pidriver/web.py +749 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_install.py +32 -8
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_manager.py +14 -0
- pidriver-0.4.1/tests/test_repository.py +167 -0
- pidriver-0.4.1/tests/test_staging.py +356 -0
- pidriver-0.4.1/tests/test_store.py +33 -0
- pidriver-0.4.1/tests/test_supervisor.py +113 -0
- pidriver-0.4.1/tests/test_web.py +85 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/.sc/last-chat-settings.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/LICENSE +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/examples/basic_prompt.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/_transport.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/assets/pi_login.mjs +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/auth.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/client.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/events.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/interaction.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/models.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/py.typed +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/session.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/usage.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/src/pidriver/workspace.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/conftest.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/README.md +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/_capture_driver.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/agent_end.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/agent_start.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_start_assistant.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_start_user.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_update.text_delta.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/ready.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/response_prompt_ack.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/tool_execution_end.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/tool_execution_start.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/tool_execution_update.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/turn_end.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/frames/turn_start.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/fixtures/payloads/get_state.response.json +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_auth.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_config.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_event_summary.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_events.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_fixtures_replay.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_install_resolve.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_models.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_replay_session.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_session.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_transport.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_ui_methods.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/tests/test_usage.py +0 -0
- {pidriver-0.2.0 → pidriver-0.4.1}/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.1
|
|
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,44 @@ 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
|
+
)
|
|
547
|
+
# Drain the session's events, then close it before finalizing the workspace.
|
|
548
|
+
async for event in session:
|
|
549
|
+
...
|
|
550
|
+
await session.aclose()
|
|
551
|
+
|
|
552
|
+
print(workspace.diff()) # review before deciding
|
|
553
|
+
state = workspace.to_state() # save for approval after a host restart if needed
|
|
554
|
+
# workspace = GitRepositoryWorkspace.from_state(state)
|
|
555
|
+
workspace.merge() # commits, merges and pushes to the cloned default branch
|
|
556
|
+
workspace.cleanup() # remove the local clone after successful push
|
|
557
|
+
# Or: workspace.discard() # no changes to origin
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
If the remote branch has moved, the push is rejected with `MergeConflict`; the local
|
|
561
|
+
workspace remains available for inspection. Never accept arbitrary repository URLs from
|
|
562
|
+
untrusted requests: the caller must enforce an allowlist. Empty repositories and a
|
|
563
|
+
second job with the same ID are rejected. SSH also accepts standard `ssh://` and
|
|
564
|
+
`git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
|
|
565
|
+
later pushes; the key must still exist then. SSH uses strict host-key checking and
|
|
566
|
+
non-interactive authentication; configure `known_hosts` on the host in advance.
|
|
567
|
+
|
|
525
568
|
## Workspace jail
|
|
526
569
|
|
|
527
570
|
Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
|
|
@@ -574,6 +617,41 @@ await login.submit_code(info["session"], code) # accepts a bare code or a ca
|
|
|
574
617
|
locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
|
|
575
618
|
works too) — both exported for hosts building their own extension/auth tooling.
|
|
576
619
|
|
|
620
|
+
## Web instance service
|
|
621
|
+
|
|
622
|
+
The web subsystem is an optional layer over the same driver core. `pidriver` stays
|
|
623
|
+
dependency-free by default; install the web extra only for the ASGI server:
|
|
624
|
+
|
|
625
|
+
```sh
|
|
626
|
+
uv pip install -e ".[web]"
|
|
627
|
+
uv run pidriver-web --store pidriver.sqlite3 \
|
|
628
|
+
--projects-root /srv/projects --allow-project acme
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Core pieces:
|
|
632
|
+
|
|
633
|
+
- `PiInstanceSupervisor` drains every `PiSession` in one background task, persists event history,
|
|
634
|
+
broadcasts events to subscribers, and exposes `prompt` / `respond` / `cancel` / `stop`.
|
|
635
|
+
- `PiInstanceStore` stores snapshots and event logs in SQLite. On service restart, previously live
|
|
636
|
+
subprocesses are marked `stale` because they cannot be reattached.
|
|
637
|
+
- `PiWebApp` is a small ASGI app with JSON endpoints and SSE streaming.
|
|
638
|
+
|
|
639
|
+
Useful routes:
|
|
640
|
+
|
|
641
|
+
| Route | Purpose |
|
|
642
|
+
| --- | --- |
|
|
643
|
+
| `GET /` | Minimal dashboard for launching and watching instances. |
|
|
644
|
+
| `GET /api/projects` | List project-jail candidates. |
|
|
645
|
+
| `GET /api/models` | List models visible to this isolated pi config. |
|
|
646
|
+
| `POST /api/instances` | Start an instance from `{prompt, project, model, approval_policy}`. |
|
|
647
|
+
| `GET /api/instances` / `GET /api/instances/{id}` | List or inspect snapshots. |
|
|
648
|
+
| `GET /api/instances/{id}/events` | Read persisted event history. |
|
|
649
|
+
| `GET /api/instances/{id}/events/stream` | SSE stream for live events. |
|
|
650
|
+
| `POST /api/instances/{id}/prompt` | Send a follow-up prompt. |
|
|
651
|
+
| `POST /api/instances/{id}/respond` | Answer a pending interaction. |
|
|
652
|
+
| `POST /api/instances/{id}/cancel` / `POST /api/instances/{id}/stop` | Interrupt or terminate. |
|
|
653
|
+
| `GET /api/auth/providers` / `POST /api/auth/{provider}/start` | OAuth helper endpoints. |
|
|
654
|
+
|
|
577
655
|
## Development
|
|
578
656
|
|
|
579
657
|
```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,44 @@ 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
|
+
)
|
|
521
|
+
# Drain the session's events, then close it before finalizing the workspace.
|
|
522
|
+
async for event in session:
|
|
523
|
+
...
|
|
524
|
+
await session.aclose()
|
|
525
|
+
|
|
526
|
+
print(workspace.diff()) # review before deciding
|
|
527
|
+
state = workspace.to_state() # save for approval after a host restart if needed
|
|
528
|
+
# workspace = GitRepositoryWorkspace.from_state(state)
|
|
529
|
+
workspace.merge() # commits, merges and pushes to the cloned default branch
|
|
530
|
+
workspace.cleanup() # remove the local clone after successful push
|
|
531
|
+
# Or: workspace.discard() # no changes to origin
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
If the remote branch has moved, the push is rejected with `MergeConflict`; the local
|
|
535
|
+
workspace remains available for inspection. Never accept arbitrary repository URLs from
|
|
536
|
+
untrusted requests: the caller must enforce an allowlist. Empty repositories and a
|
|
537
|
+
second job with the same ID are rejected. SSH also accepts standard `ssh://` and
|
|
538
|
+
`git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
|
|
539
|
+
later pushes; the key must still exist then. SSH uses strict host-key checking and
|
|
540
|
+
non-interactive authentication; configure `known_hosts` on the host in advance.
|
|
541
|
+
|
|
501
542
|
## Workspace jail
|
|
502
543
|
|
|
503
544
|
Run an agent loose inside a project without letting it run anywhere on disk. `resolve_project`
|
|
@@ -550,6 +591,41 @@ await login.submit_code(info["session"], code) # accepts a bare code or a ca
|
|
|
550
591
|
locates pi's bundled Node libraries (package identity from `config.npm_package`, so the omp fork
|
|
551
592
|
works too) — both exported for hosts building their own extension/auth tooling.
|
|
552
593
|
|
|
594
|
+
## Web instance service
|
|
595
|
+
|
|
596
|
+
The web subsystem is an optional layer over the same driver core. `pidriver` stays
|
|
597
|
+
dependency-free by default; install the web extra only for the ASGI server:
|
|
598
|
+
|
|
599
|
+
```sh
|
|
600
|
+
uv pip install -e ".[web]"
|
|
601
|
+
uv run pidriver-web --store pidriver.sqlite3 \
|
|
602
|
+
--projects-root /srv/projects --allow-project acme
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Core pieces:
|
|
606
|
+
|
|
607
|
+
- `PiInstanceSupervisor` drains every `PiSession` in one background task, persists event history,
|
|
608
|
+
broadcasts events to subscribers, and exposes `prompt` / `respond` / `cancel` / `stop`.
|
|
609
|
+
- `PiInstanceStore` stores snapshots and event logs in SQLite. On service restart, previously live
|
|
610
|
+
subprocesses are marked `stale` because they cannot be reattached.
|
|
611
|
+
- `PiWebApp` is a small ASGI app with JSON endpoints and SSE streaming.
|
|
612
|
+
|
|
613
|
+
Useful routes:
|
|
614
|
+
|
|
615
|
+
| Route | Purpose |
|
|
616
|
+
| --- | --- |
|
|
617
|
+
| `GET /` | Minimal dashboard for launching and watching instances. |
|
|
618
|
+
| `GET /api/projects` | List project-jail candidates. |
|
|
619
|
+
| `GET /api/models` | List models visible to this isolated pi config. |
|
|
620
|
+
| `POST /api/instances` | Start an instance from `{prompt, project, model, approval_policy}`. |
|
|
621
|
+
| `GET /api/instances` / `GET /api/instances/{id}` | List or inspect snapshots. |
|
|
622
|
+
| `GET /api/instances/{id}/events` | Read persisted event history. |
|
|
623
|
+
| `GET /api/instances/{id}/events/stream` | SSE stream for live events. |
|
|
624
|
+
| `POST /api/instances/{id}/prompt` | Send a follow-up prompt. |
|
|
625
|
+
| `POST /api/instances/{id}/respond` | Answer a pending interaction. |
|
|
626
|
+
| `POST /api/instances/{id}/cancel` / `POST /api/instances/{id}/stop` | Interrupt or terminate. |
|
|
627
|
+
| `GET /api/auth/providers` / `POST /api/auth/{provider}/start` | OAuth helper endpoints. |
|
|
628
|
+
|
|
553
629
|
## Development
|
|
554
630
|
|
|
555
631
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pidriver"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
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"]
|
|
@@ -56,6 +56,7 @@ from .config import (
|
|
|
56
56
|
PiConfig,
|
|
57
57
|
)
|
|
58
58
|
from .errors import (
|
|
59
|
+
MergeConflict,
|
|
59
60
|
PiCommandError,
|
|
60
61
|
PiDriverError,
|
|
61
62
|
PiInstallError,
|
|
@@ -65,6 +66,7 @@ from .errors import (
|
|
|
65
66
|
PiTimeoutError,
|
|
66
67
|
ProjectError,
|
|
67
68
|
SessionNotFoundError,
|
|
69
|
+
StagingError,
|
|
68
70
|
TransportClosedError,
|
|
69
71
|
)
|
|
70
72
|
from .events import (
|
|
@@ -86,6 +88,15 @@ from .events import (
|
|
|
86
88
|
is_freetext_option,
|
|
87
89
|
parse_event,
|
|
88
90
|
)
|
|
91
|
+
from .instances import (
|
|
92
|
+
ApprovalPolicy,
|
|
93
|
+
EventEnvelope,
|
|
94
|
+
InstanceStatus,
|
|
95
|
+
PiInstanceSnapshot,
|
|
96
|
+
PiInstanceSpec,
|
|
97
|
+
event_to_dict,
|
|
98
|
+
interaction_to_dict,
|
|
99
|
+
)
|
|
89
100
|
from .interaction import (
|
|
90
101
|
ASK_FIRST_SYSTEM_PROMPT,
|
|
91
102
|
AskHost,
|
|
@@ -98,11 +109,21 @@ from .interaction import (
|
|
|
98
109
|
)
|
|
99
110
|
from .manager import ManagedSession, PiSessionManager
|
|
100
111
|
from .models import ModelInfo, list_models, parse_models
|
|
112
|
+
from .repository import GitRepositoryWorkspace, stage_repository, start_repository_task
|
|
101
113
|
from .session import PiSession
|
|
114
|
+
from .staging import (
|
|
115
|
+
DEFAULT_COPY_EXCLUDES,
|
|
116
|
+
DEFAULT_MAX_COPY_BYTES,
|
|
117
|
+
MergeResult,
|
|
118
|
+
StagedWorkspace,
|
|
119
|
+
stage_workspace,
|
|
120
|
+
)
|
|
121
|
+
from .store import PiInstanceStore
|
|
122
|
+
from .supervisor import PiInstanceSupervisor
|
|
102
123
|
from .usage import UsageTotals
|
|
103
124
|
from .workspace import ProjectDir, list_candidate_projects, list_projects, resolve_project
|
|
104
125
|
|
|
105
|
-
__version__ = "0.
|
|
126
|
+
__version__ = "0.4.1"
|
|
106
127
|
|
|
107
128
|
__all__ = [
|
|
108
129
|
"__version__",
|
|
@@ -156,11 +177,30 @@ __all__ = [
|
|
|
156
177
|
"chain",
|
|
157
178
|
"response_to_wire",
|
|
158
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",
|
|
159
190
|
# workspace
|
|
160
191
|
"resolve_project",
|
|
161
192
|
"list_projects",
|
|
162
193
|
"list_candidate_projects",
|
|
163
194
|
"ProjectDir",
|
|
195
|
+
# staging
|
|
196
|
+
"stage_workspace",
|
|
197
|
+
"stage_repository",
|
|
198
|
+
"start_repository_task",
|
|
199
|
+
"StagedWorkspace",
|
|
200
|
+
"GitRepositoryWorkspace",
|
|
201
|
+
"MergeResult",
|
|
202
|
+
"DEFAULT_COPY_EXCLUDES",
|
|
203
|
+
"DEFAULT_MAX_COPY_BYTES",
|
|
164
204
|
# models
|
|
165
205
|
"ModelInfo",
|
|
166
206
|
"parse_models",
|
|
@@ -180,4 +220,6 @@ __all__ = [
|
|
|
180
220
|
"TransportClosedError",
|
|
181
221
|
"SessionNotFoundError",
|
|
182
222
|
"ProjectError",
|
|
223
|
+
"StagingError",
|
|
224
|
+
"MergeConflict",
|
|
183
225
|
]
|
|
@@ -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
|
|
@@ -19,6 +19,8 @@ __all__ = [
|
|
|
19
19
|
"TransportClosedError",
|
|
20
20
|
"SessionNotFoundError",
|
|
21
21
|
"ProjectError",
|
|
22
|
+
"StagingError",
|
|
23
|
+
"MergeConflict",
|
|
22
24
|
]
|
|
23
25
|
|
|
24
26
|
|
|
@@ -35,6 +37,43 @@ class ProjectError(PiDriverError, ValueError):
|
|
|
35
37
|
"""
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
class StagingError(PiDriverError):
|
|
41
|
+
"""A staging operation (stage/diff/merge/discard/cleanup) failed structurally.
|
|
42
|
+
|
|
43
|
+
Raised by :mod:`pidriver.staging` when a ``git`` command exits non-zero, the
|
|
44
|
+
staging root is unusable, the source/stage path is invalid, the source repo is
|
|
45
|
+
in a state that cannot be merged into (dirty working tree, detached HEAD), or a
|
|
46
|
+
non-git copy exceeds the size cap. Carries the failing git ``argv`` + ``stderr``
|
|
47
|
+
when the cause was a git invocation.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
argv: tuple[str, ...] | None = None,
|
|
55
|
+
stderr: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.argv = argv
|
|
58
|
+
self.stderr = stderr
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MergeConflict(StagingError):
|
|
63
|
+
"""A staged change could not be landed into the source cleanly.
|
|
64
|
+
|
|
65
|
+
The ``pi/<job>`` branch (git) or the staged tree (non-git) is preserved
|
|
66
|
+
untouched and the source is left pristine, so the user can resolve by hand.
|
|
67
|
+
``conflicts`` lists the conflicting paths; ``diff`` is the unified diff to
|
|
68
|
+
reconcile.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str, *, conflicts: tuple[str, ...], diff: str) -> None:
|
|
72
|
+
self.conflicts = conflicts
|
|
73
|
+
self.diff = diff
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
|
|
76
|
+
|
|
38
77
|
class PiNotFoundError(PiDriverError):
|
|
39
78
|
"""The pi/omp executable could not be located or is not runnable."""
|
|
40
79
|
|
|
@@ -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)
|