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