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.
Files changed (80) hide show
  1. {pidriver-0.3.0 → pidriver-0.4.2}/.gitignore +1 -0
  2. pidriver-0.4.2/.sc/last-chat-settings.json +4 -0
  3. {pidriver-0.3.0 → pidriver-0.4.2}/PKG-INFO +85 -3
  4. {pidriver-0.3.0 → pidriver-0.4.2}/README.md +81 -1
  5. {pidriver-0.3.0 → pidriver-0.4.2}/pyproject.toml +7 -1
  6. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/__init__.py +26 -1
  7. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/_install.py +10 -2
  8. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/config.py +1 -1
  9. pidriver-0.4.2/src/pidriver/instances.py +241 -0
  10. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/manager.py +7 -3
  11. pidriver-0.4.2/src/pidriver/repository.py +220 -0
  12. pidriver-0.4.2/src/pidriver/store.py +269 -0
  13. pidriver-0.4.2/src/pidriver/supervisor.py +424 -0
  14. pidriver-0.4.2/src/pidriver/web.py +749 -0
  15. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_install.py +32 -8
  16. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_manager.py +14 -0
  17. pidriver-0.4.2/tests/test_repository.py +240 -0
  18. pidriver-0.4.2/tests/test_store.py +33 -0
  19. pidriver-0.4.2/tests/test_supervisor.py +113 -0
  20. pidriver-0.4.2/tests/test_web.py +85 -0
  21. {pidriver-0.3.0 → pidriver-0.4.2}/LICENSE +0 -0
  22. {pidriver-0.3.0 → pidriver-0.4.2}/examples/basic_prompt.py +0 -0
  23. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/_transport.py +0 -0
  24. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/assets/pi_login.mjs +0 -0
  25. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/auth.py +0 -0
  26. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/client.py +0 -0
  27. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/errors.py +0 -0
  28. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/events.py +0 -0
  29. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/interaction.py +0 -0
  30. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/models.py +0 -0
  31. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/py.typed +0 -0
  32. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/session.py +0 -0
  33. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/staging.py +0 -0
  34. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/usage.py +0 -0
  35. {pidriver-0.3.0 → pidriver-0.4.2}/src/pidriver/workspace.py +0 -0
  36. {pidriver-0.3.0 → pidriver-0.4.2}/tests/conftest.py +0 -0
  37. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/README.md +0 -0
  38. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/_capture_driver.py +0 -0
  39. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/agent_end.json +0 -0
  40. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/agent_start.json +0 -0
  41. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
  42. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
  43. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
  44. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
  45. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
  46. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
  47. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
  48. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
  49. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
  50. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
  51. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
  52. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_start_assistant.json +0 -0
  53. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_start_user.json +0 -0
  54. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.text_delta.json +0 -0
  55. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
  56. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
  57. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
  58. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/ready.json +0 -0
  59. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/response_prompt_ack.json +0 -0
  60. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_end.json +0 -0
  61. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_start.json +0 -0
  62. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_update.json +0 -0
  63. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/turn_end.json +0 -0
  64. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/frames/turn_start.json +0 -0
  65. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
  66. {pidriver-0.3.0 → pidriver-0.4.2}/tests/fixtures/payloads/get_state.response.json +0 -0
  67. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_auth.py +0 -0
  68. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_config.py +0 -0
  69. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_event_summary.py +0 -0
  70. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_events.py +0 -0
  71. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_fixtures_replay.py +0 -0
  72. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_install_resolve.py +0 -0
  73. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_models.py +0 -0
  74. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_replay_session.py +0 -0
  75. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_session.py +0 -0
  76. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_staging.py +0 -0
  77. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_transport.py +0 -0
  78. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_ui_methods.py +0 -0
  79. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_usage.py +0 -0
  80. {pidriver-0.3.0 → pidriver-0.4.2}/tests/test_workspace.py +0 -0
@@ -31,3 +31,4 @@ scratch/
31
31
 
32
32
  # Secrets (PyPI token, etc.)
33
33
  .env
34
+ .empryo
@@ -0,0 +1,4 @@
1
+ {
2
+ "provider_key": "codex",
3
+ "reasoning_effort": "high"
4
+ }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pidriver
3
- Version: 0.3.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.78.1"` | Pinned pi version to install (overridable). |
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.78.1"` | Pinned pi version to install (overridable). |
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.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.3.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 previously-installed managed copy.
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
- return str(managed)
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.78.1" # known-good pin; overridable
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
- async with self._lock:
111
- self._guard_capacity()
112
- self._sessions[session.session_id] = session
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