piocloop 0.1.0__py3-none-any.whl
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.
- piocloop/__init__.py +1 -0
- piocloop/__main__.py +3 -0
- piocloop/cli.py +230 -0
- piocloop/loop.py +487 -0
- piocloop/pi_client.py +441 -0
- piocloop/pi_events.py +326 -0
- piocloop/plan_parser.py +140 -0
- piocloop/tui.py +289 -0
- piocloop-0.1.0.dist-info/METADATA +191 -0
- piocloop-0.1.0.dist-info/RECORD +13 -0
- piocloop-0.1.0.dist-info/WHEEL +4 -0
- piocloop-0.1.0.dist-info/entry_points.txt +2 -0
- piocloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piocloop/tui.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Textual TUI for piocloop — a thin view over LoopEngine.
|
|
2
|
+
|
|
3
|
+
All orchestration lives in `loop.py` so it can be tested without a terminal;
|
|
4
|
+
this module only renders state and translates key presses into engine calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import IO, Optional
|
|
15
|
+
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.widgets import Footer, RichLog, Static
|
|
19
|
+
|
|
20
|
+
from .loop import (
|
|
21
|
+
STATE_COMPLETE,
|
|
22
|
+
STATE_ERROR,
|
|
23
|
+
STATE_PAUSED,
|
|
24
|
+
STATE_PAUSING,
|
|
25
|
+
STATE_READY,
|
|
26
|
+
STATE_RUNNING,
|
|
27
|
+
STATE_STALLED,
|
|
28
|
+
STATE_STARTING,
|
|
29
|
+
LoopConfig,
|
|
30
|
+
LoopEngine,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_STATE_ICONS = {
|
|
34
|
+
STATE_STARTING: "◐",
|
|
35
|
+
STATE_READY: "●",
|
|
36
|
+
STATE_RUNNING: "▶",
|
|
37
|
+
STATE_PAUSING: "◑",
|
|
38
|
+
STATE_PAUSED: "⏸",
|
|
39
|
+
STATE_STALLED: "⚠",
|
|
40
|
+
STATE_COMPLETE: "✓",
|
|
41
|
+
STATE_ERROR: "✗",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_STATE_COLORS = {
|
|
45
|
+
STATE_STARTING: "yellow",
|
|
46
|
+
STATE_READY: "cyan",
|
|
47
|
+
STATE_RUNNING: "green",
|
|
48
|
+
STATE_PAUSING: "yellow",
|
|
49
|
+
STATE_PAUSED: "yellow",
|
|
50
|
+
STATE_STALLED: "orange1",
|
|
51
|
+
STATE_COMPLETE: "bright_green",
|
|
52
|
+
STATE_ERROR: "red",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_LOG_COLORS = {
|
|
56
|
+
"start": "cyan", "idle": "cyan", "task": "yellow", "edit": "green",
|
|
57
|
+
"error": "red", "tool": "magenta", "read": "blue", "ai": "white",
|
|
58
|
+
"think": "dim", "info": "dim", "complete": "bright_green",
|
|
59
|
+
"warn": "orange1", "dialog": "orange1", "retry": "orange1",
|
|
60
|
+
"compaction": "blue", "status": "dim", "stderr": "dim",
|
|
61
|
+
"extension": "orange1", "protocol": "orange1",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _escape(text: str) -> str:
|
|
66
|
+
"""Agent output must never be interpreted as Rich markup."""
|
|
67
|
+
return text.replace("[", r"\[")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Header(Static):
|
|
71
|
+
DEFAULT_CSS = """
|
|
72
|
+
_Header {
|
|
73
|
+
height: auto;
|
|
74
|
+
background: $panel;
|
|
75
|
+
border-bottom: solid $primary;
|
|
76
|
+
padding: 0 1;
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PiloopApp(App):
|
|
82
|
+
CSS = """
|
|
83
|
+
Screen { layout: vertical; }
|
|
84
|
+
_Header { height: auto; min-height: 3; }
|
|
85
|
+
RichLog { height: 1fr; border: none; scrollbar-gutter: stable; }
|
|
86
|
+
Footer { height: 1; }
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
BINDINGS = [
|
|
90
|
+
Binding("s", "start_loop", "Start", show=True),
|
|
91
|
+
Binding("space", "toggle_pause", "Pause", show=True),
|
|
92
|
+
Binding("a", "abort_now", "Abort iter", show=True),
|
|
93
|
+
Binding("r", "retry", "Retry", show=True),
|
|
94
|
+
Binding("q", "request_quit", "Quit", show=True),
|
|
95
|
+
Binding("ctrl+c", "request_quit", "Quit", show=False),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
config: LoopConfig,
|
|
101
|
+
*,
|
|
102
|
+
model: Optional[str] = None,
|
|
103
|
+
auto_run: bool = False,
|
|
104
|
+
log_file: Optional[Path] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self._config = config
|
|
108
|
+
self._model = model
|
|
109
|
+
self._auto_run = auto_run
|
|
110
|
+
self._log_fh: Optional[IO[str]] = (
|
|
111
|
+
open(log_file, "a", encoding="utf-8") if log_file else None
|
|
112
|
+
)
|
|
113
|
+
self.engine = LoopEngine(config, ui=self)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Compose / lifecycle
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def compose(self) -> ComposeResult:
|
|
120
|
+
yield _Header()
|
|
121
|
+
yield RichLog(id="log", auto_scroll=True, highlight=False, markup=True, wrap=True)
|
|
122
|
+
yield Footer()
|
|
123
|
+
|
|
124
|
+
def on_mount(self) -> None:
|
|
125
|
+
self._refresh_header()
|
|
126
|
+
self.run_worker(self._worker_boot(), name="boot")
|
|
127
|
+
self.set_interval(1.0, self._refresh_header)
|
|
128
|
+
self.set_interval(4.0, self.engine.reload_plan)
|
|
129
|
+
|
|
130
|
+
async def _worker_boot(self) -> None:
|
|
131
|
+
try:
|
|
132
|
+
if not await self.engine.boot():
|
|
133
|
+
return
|
|
134
|
+
await self.engine.run(auto_start=self._auto_run)
|
|
135
|
+
finally:
|
|
136
|
+
await self.engine.shutdown()
|
|
137
|
+
|
|
138
|
+
async def on_unmount(self) -> None:
|
|
139
|
+
await self.engine.shutdown()
|
|
140
|
+
if self._log_fh:
|
|
141
|
+
self._log_fh.close()
|
|
142
|
+
self._log_fh = None
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# LoopUI implementation
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def log_line(self, kind: str, text: str, detail: str = "") -> None:
|
|
149
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
150
|
+
if self._log_fh:
|
|
151
|
+
suffix = f" | {detail}" if detail else ""
|
|
152
|
+
self._log_fh.write(f"{ts} [{kind}] {text}{suffix}\n")
|
|
153
|
+
self._log_fh.flush()
|
|
154
|
+
color = _LOG_COLORS.get(kind, "white")
|
|
155
|
+
detail_str = f" [dim]{_escape(detail[:100])}[/dim]" if detail else ""
|
|
156
|
+
with contextlib.suppress(Exception):
|
|
157
|
+
self.query_one(RichLog).write(
|
|
158
|
+
f"[dim]{ts}[/dim] [{color}][{kind}][/{color}] {_escape(text)}{detail_str}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def set_state(self, state: str) -> None:
|
|
162
|
+
self._refresh_header()
|
|
163
|
+
|
|
164
|
+
def on_progress(self) -> None:
|
|
165
|
+
self._refresh_header()
|
|
166
|
+
|
|
167
|
+
def on_complete(self, summary: str) -> None:
|
|
168
|
+
with contextlib.suppress(Exception):
|
|
169
|
+
log = self.query_one(RichLog)
|
|
170
|
+
log.write("")
|
|
171
|
+
log.write("[bright_green bold]╔══════════════════════════════════════╗[/bright_green bold]")
|
|
172
|
+
log.write("[bright_green bold]║ PLAN COMPLETE ║[/bright_green bold]")
|
|
173
|
+
log.write("[bright_green bold]╚══════════════════════════════════════╝[/bright_green bold]")
|
|
174
|
+
log.write("")
|
|
175
|
+
for line in summary.splitlines():
|
|
176
|
+
log.write(f" {_escape(line)}")
|
|
177
|
+
log.write("")
|
|
178
|
+
log.write("[dim]Press Q to quit.[/dim]")
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Header
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _refresh_header(self) -> None:
|
|
185
|
+
with contextlib.suppress(Exception):
|
|
186
|
+
self.query_one(_Header).update(self._build_header())
|
|
187
|
+
|
|
188
|
+
def _build_header(self) -> str:
|
|
189
|
+
engine = self.engine
|
|
190
|
+
icon = _STATE_ICONS.get(engine.state, "?")
|
|
191
|
+
color = _STATE_COLORS.get(engine.state, "white")
|
|
192
|
+
state_str = f"[{color}]{icon} {engine.state.upper()}[/{color}]"
|
|
193
|
+
|
|
194
|
+
if engine.progress:
|
|
195
|
+
p = engine.progress
|
|
196
|
+
automatable = p.completed + p.pending
|
|
197
|
+
pct = p.percent_complete
|
|
198
|
+
filled = round(20 * pct / 100)
|
|
199
|
+
bar = "█" * filled + "░" * (20 - filled)
|
|
200
|
+
progress_str = f"[{p.completed}/{automatable}] [{bar}] {pct}%"
|
|
201
|
+
if p.blocked:
|
|
202
|
+
progress_str += f" [red]{p.blocked} blocked[/red]"
|
|
203
|
+
else:
|
|
204
|
+
progress_str = ""
|
|
205
|
+
|
|
206
|
+
iter_str = (
|
|
207
|
+
f"iter:{engine.iteration}/{engine.config.max_iterations}"
|
|
208
|
+
if engine.iteration
|
|
209
|
+
else ""
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
elapsed_str = ""
|
|
213
|
+
if engine.iter_start_time and engine.state in (STATE_RUNNING, STATE_PAUSING):
|
|
214
|
+
e = int(time.monotonic() - engine.iter_start_time)
|
|
215
|
+
elapsed_str = f"{e // 60}:{e % 60:02d}"
|
|
216
|
+
avg_str = ""
|
|
217
|
+
if engine.iter_times:
|
|
218
|
+
a = int(sum(engine.iter_times) / len(engine.iter_times))
|
|
219
|
+
avg_str = f"avg:{a // 60}:{a % 60:02d}"
|
|
220
|
+
|
|
221
|
+
model_str = f"[dim]{self._model}[/dim]" if self._model else ""
|
|
222
|
+
tok_str = f"[dim]tok:{engine.total_tokens:,}[/dim]" if engine.total_tokens else ""
|
|
223
|
+
cost_str = f"[dim]${engine.total_cost:.4f}[/dim]" if engine.total_cost else ""
|
|
224
|
+
|
|
225
|
+
if engine.state == STATE_COMPLETE:
|
|
226
|
+
task_str = "[bright_green]All tasks complete![/bright_green]"
|
|
227
|
+
elif engine.state == STATE_READY:
|
|
228
|
+
task_str = "[dim]Press [bold]S[/bold] to start[/dim]"
|
|
229
|
+
elif engine.state == STATE_STALLED:
|
|
230
|
+
task_str = (
|
|
231
|
+
f"[orange1]No progress for {engine.stall_count} iterations — "
|
|
232
|
+
f"press [bold]R[/bold] to continue or [bold]Q[/bold] to quit[/orange1]"
|
|
233
|
+
)
|
|
234
|
+
elif engine.state == STATE_ERROR:
|
|
235
|
+
task_str = "[dim]Press [bold]R[/bold] to retry or [bold]Q[/bold] to quit[/dim]"
|
|
236
|
+
elif engine.state == STATE_PAUSING:
|
|
237
|
+
task_str = (
|
|
238
|
+
"[yellow]Finishing current iteration — "
|
|
239
|
+
"press [bold]A[/bold] to abort it now[/yellow]"
|
|
240
|
+
)
|
|
241
|
+
elif engine.current_task:
|
|
242
|
+
task_str = f"[yellow]{_escape(engine.current_task[:100])}[/yellow]"
|
|
243
|
+
else:
|
|
244
|
+
task_str = ""
|
|
245
|
+
|
|
246
|
+
row1 = " ".join(x for x in [state_str, progress_str, iter_str, elapsed_str, avg_str] if x)
|
|
247
|
+
row2 = " ".join(x for x in [model_str, tok_str, cost_str] if x)
|
|
248
|
+
lines = [row1]
|
|
249
|
+
if row2:
|
|
250
|
+
lines.append(row2)
|
|
251
|
+
if task_str:
|
|
252
|
+
lines.append(task_str)
|
|
253
|
+
return "\n".join(lines)
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Actions
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def action_start_loop(self) -> None:
|
|
260
|
+
if self.engine.state == STATE_READY:
|
|
261
|
+
self.engine.request_start()
|
|
262
|
+
|
|
263
|
+
def action_toggle_pause(self) -> None:
|
|
264
|
+
if self.engine.state == STATE_RUNNING:
|
|
265
|
+
self.engine.request_pause()
|
|
266
|
+
self.log_line("info", "Pause requested — will stop after this iteration")
|
|
267
|
+
elif self.engine.state in (STATE_PAUSED, STATE_PAUSING):
|
|
268
|
+
self.engine.request_resume()
|
|
269
|
+
self._refresh_header()
|
|
270
|
+
|
|
271
|
+
def action_abort_now(self) -> None:
|
|
272
|
+
if self.engine.state in (STATE_RUNNING, STATE_PAUSING):
|
|
273
|
+
self.log_line("warn", "Abort requested")
|
|
274
|
+
self.run_worker(self.engine.abort_current(), name="abort")
|
|
275
|
+
|
|
276
|
+
def action_retry(self) -> None:
|
|
277
|
+
if self.engine.state in (STATE_ERROR, STATE_STALLED):
|
|
278
|
+
self.log_line("info", "Retrying")
|
|
279
|
+
self.engine.request_retry()
|
|
280
|
+
|
|
281
|
+
def action_request_quit(self) -> None:
|
|
282
|
+
self.engine.request_stop()
|
|
283
|
+
self.run_worker(self._quit_soon(), name="quit")
|
|
284
|
+
|
|
285
|
+
async def _quit_soon(self) -> None:
|
|
286
|
+
with contextlib.suppress(Exception):
|
|
287
|
+
await asyncio.wait_for(self.engine.abort_current(), timeout=5.0)
|
|
288
|
+
await self.engine.shutdown()
|
|
289
|
+
self.exit()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: piocloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TUI loop harness that orchestrates the PI coding agent to execute tasks from a PLAN.md file iteratively
|
|
5
|
+
Project-URL: Homepage, https://github.com/rbarzic/piocloop
|
|
6
|
+
Project-URL: Repository, https://github.com/rbarzic/piocloop
|
|
7
|
+
Project-URL: Issues, https://github.com/rbarzic/piocloop/issues
|
|
8
|
+
Author-email: Ronan Barzic <rbarzic@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,automation,pi,pi-coding-agent,textual,tui
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: textual>=0.80
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# piocloop
|
|
29
|
+
|
|
30
|
+
A Python TUI that orchestrates the [PI coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
|
|
31
|
+
to execute tasks from a `PLAN.md` file iteratively, one session at a time.
|
|
32
|
+
|
|
33
|
+
piocloop is a port of [pyocloop](https://github.com/rbarzic/pyocloop) — same
|
|
34
|
+
concept, but it drives `pi --mode rpc` over stdin/stdout JSONL instead of an
|
|
35
|
+
OpenCode HTTP server plus SSE stream. That change, plus a real watchdog, fixes
|
|
36
|
+
the hangs documented in [`ANALYSIS.md`](ANALYSIS.md).
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. piocloop starts a `pi --mode rpc` subprocess
|
|
41
|
+
2. On each iteration it starts a fresh session, sends your loop prompt (with the
|
|
42
|
+
plan file path injected), and waits for `agent_settled`
|
|
43
|
+
3. PI reads the plan, executes the next task, marks it `[x]`, and appends
|
|
44
|
+
`<plan-complete>` when everything is done
|
|
45
|
+
4. The TUI shows live progress: task counter, progress bar, current task, token
|
|
46
|
+
count, cost, and elapsed/average time per iteration
|
|
47
|
+
5. The loop stops when PI writes `<plan-complete>` — or when the watchdog, an
|
|
48
|
+
error streak, a stall, or `--max-iterations` intervenes
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
- Python 3.11+
|
|
53
|
+
- `pi` on your PATH, configured with a provider (developed against pi 0.82.1)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install -g @earendil-works/pi-coding-agent
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/rbarzic/piocloop
|
|
63
|
+
cd piocloop
|
|
64
|
+
pip install .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
piloop doctor # check pi is installed and reachable
|
|
71
|
+
piloop bootstrap . # create starter PLAN.md and .loop-prompt.md
|
|
72
|
+
piloop run --model zai/glm-5.2
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Using an OpenRouter model
|
|
76
|
+
|
|
77
|
+
Set your OpenRouter API key, then prefix the OpenRouter model ID with
|
|
78
|
+
`openrouter/`. For example, Kimi K3 is `moonshotai/kimi-k3` on OpenRouter:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export OPENROUTER_API_KEY="your-openrouter-api-key"
|
|
82
|
+
piloop run --model openrouter/moonshotai/kimi-k3
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You can check that PI's model catalog contains it with:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pi --list-models openrouter | grep kimi-k3
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `piloop run`
|
|
92
|
+
|
|
93
|
+
| Option | Default | Meaning |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `-m, --model` | pi's default | Model pattern or `provider/id` |
|
|
96
|
+
| `--thinking` | pi's default | `off\|minimal\|low\|medium\|high\|xhigh\|max` |
|
|
97
|
+
| `--plan` | `PLAN.md` | Plan file |
|
|
98
|
+
| `--prompt` | `.loop-prompt.md` | Loop prompt file |
|
|
99
|
+
| `-r, --run` | off | Start iterating immediately |
|
|
100
|
+
| `--max-iterations` | 100 | Hard stop |
|
|
101
|
+
| `--iteration-timeout` | 1800 | Seconds before an iteration is aborted |
|
|
102
|
+
| `--max-stalls` | 3 | Stop after N iterations with no plan progress (0 disables) |
|
|
103
|
+
| `--dialog-policy` | `cancel` | How to answer blocking extension dialogs |
|
|
104
|
+
| `--session-dir`, `--no-session` | — | Passed through to pi |
|
|
105
|
+
| `--tools`, `--exclude-tools` | — | Passed through to pi |
|
|
106
|
+
| `--append-system-prompt`, `--skill` | — | Passed through to pi (repeatable) |
|
|
107
|
+
| `--approve / --no-approve` | pi's default | Project-local file trust |
|
|
108
|
+
| `--verbose`, `--log`, `--debug` | — | Diagnostics |
|
|
109
|
+
|
|
110
|
+
Keys: `S` start · `Space` pause · `A` abort current iteration · `R` retry ·
|
|
111
|
+
`Q` quit.
|
|
112
|
+
|
|
113
|
+
## Plan format
|
|
114
|
+
|
|
115
|
+
```markdown
|
|
116
|
+
- [ ] a task to do
|
|
117
|
+
- [x] a completed task
|
|
118
|
+
- [MANUAL] something a human must do — never attempted, excluded from progress
|
|
119
|
+
- [BLOCKED: reason] something the agent could not finish
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The run ends when the agent appends, at the start of a line:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
<plan-complete>summary of what was done</plan-complete>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Agent skill (optional)
|
|
129
|
+
|
|
130
|
+
`skills/piocloop-setup/` is an [Agent Skills](https://agentskills.io/specification)
|
|
131
|
+
package you can copy into your own agent so it can set up piocloop runs for you —
|
|
132
|
+
turning "for each of these 40 items, do X" into a correctly formatted `PLAN.md`
|
|
133
|
+
and `.loop-prompt.md`. It is not loaded automatically; install it only if you
|
|
134
|
+
want it:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
cp -r skills/piocloop-setup ~/.pi/agent/skills/ # PI
|
|
138
|
+
cp -r skills/piocloop-setup ~/.claude/skills/ # Claude Code
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See [`skills/README.md`](skills/README.md) for other harnesses and for pointing
|
|
142
|
+
`pi` at this checkout without copying.
|
|
143
|
+
|
|
144
|
+
## Differences from pyocloop
|
|
145
|
+
|
|
146
|
+
| | pyocloop | piocloop |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| Agent | OpenCode | PI |
|
|
149
|
+
| Transport | HTTP + SSE on port 4096 | stdin/stdout JSONL |
|
|
150
|
+
| "Iteration done" signal | `session.idle` SSE event | `agent_settled` RPC event |
|
|
151
|
+
| Idle fallback | polled a field that does not exist | polls `get_state().isStreaming` |
|
|
152
|
+
| Iteration timeout | none | `--iteration-timeout`, then abort, then respawn |
|
|
153
|
+
| Iteration cap | none | `--max-iterations` |
|
|
154
|
+
| Progress stalls | undetected | `STALLED` state |
|
|
155
|
+
| Repeated errors | looped forever | error-streak circuit breaker |
|
|
156
|
+
| Blocking agent dialogs | n/a | auto-answered (`--dialog-policy`) |
|
|
157
|
+
| Tests | none | 119 |
|
|
158
|
+
|
|
159
|
+
`-a/--agent` and `-p/--port` are gone: PI has no named-agent concept (use
|
|
160
|
+
`--append-system-prompt` / `--skill`) and no server to bind.
|
|
161
|
+
|
|
162
|
+
## Why the rewrite is more reliable
|
|
163
|
+
|
|
164
|
+
[`ANALYSIS.md`](ANALYSIS.md) documents the pyocloop defects that motivated this
|
|
165
|
+
port — verified against a live OpenCode server, not inferred. The two that
|
|
166
|
+
caused silent hangs:
|
|
167
|
+
|
|
168
|
+
* the server's stdout pipe was never drained after startup, so the OS pipe
|
|
169
|
+
eventually filled and the server blocked in `write()` and stopped serving;
|
|
170
|
+
* the loop's idle wait had no deadline, and its polling fallback tested a
|
|
171
|
+
`session.idle` field that OpenCode does not return — so a single missed SSE
|
|
172
|
+
event hung the run forever.
|
|
173
|
+
|
|
174
|
+
piocloop's design rules ([`DESIGN.md`](DESIGN.md) §4) are the direct response:
|
|
175
|
+
**every pipe gets a drainer, every wait gets a deadline, and lack of progress is
|
|
176
|
+
a first-class state.** The test suite mutation-checks the first two — removing
|
|
177
|
+
either fix makes the corresponding regression test hang.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
183
|
+
.venv/bin/python -m pytest tests/ -q
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Tests run against `tests/fake_pi.py`, a stub agent — no network, no API key and
|
|
187
|
+
no LLM required.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
piocloop/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
piocloop/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
|
|
3
|
+
piocloop/cli.py,sha256=m9psKUKxvrlv37p6XDVmOfcJvLoFUWXNSkq-yvd5pdE,8396
|
|
4
|
+
piocloop/loop.py,sha256=qQumOGiKmvRe2geIly2X7ejIXSv7NR1W3AXqj-AePdA,17197
|
|
5
|
+
piocloop/pi_client.py,sha256=Oi6TzEY0owQ2qCjoQUIez4xMMsApy_wJnohU3ijMF8w,16626
|
|
6
|
+
piocloop/pi_events.py,sha256=qnt150xdiupXDC_z-7-UUldbyjk-xGQSzQaORXthbzg,10996
|
|
7
|
+
piocloop/plan_parser.py,sha256=4BnuxlpdagABFq6OL8z_3nlM9vnOzlVF4Rlxl4Sto88,3829
|
|
8
|
+
piocloop/tui.py,sha256=AIL-XYgnedisNBdOdnk4C4xnd22Ikjnix2EPo50h-OY,10322
|
|
9
|
+
piocloop-0.1.0.dist-info/METADATA,sha256=dOpDJF1wEHuv62mTbVrIv01mmVExFzu2x06WJriTJKg,6966
|
|
10
|
+
piocloop-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
piocloop-0.1.0.dist-info/entry_points.txt,sha256=7IGl9ourUvFjrgIkZ8_BQ0rchCDn4XGjf93UEWIpON0,44
|
|
12
|
+
piocloop-0.1.0.dist-info/licenses/LICENSE,sha256=JScIJvidHoMUZJO2hkFY2O7aMAqgckAUyZxAyhf839M,1069
|
|
13
|
+
piocloop-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ronan Barzic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|