piocloop 0.1.0__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.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write # required for OIDC trusted publisher
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,51 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.egg
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ wheels/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Editable install artifacts
19
+ *.pth
20
+
21
+ # Distribution / packaging
22
+ MANIFEST
23
+
24
+ # Installer logs
25
+ pip-log.txt
26
+ pip-delete-this-directory.txt
27
+
28
+ # Testing
29
+ .pytest_cache/
30
+ .coverage
31
+ htmlcov/
32
+ .tox/
33
+
34
+ # Type checking
35
+ .mypy_cache/
36
+ .ruff_cache/
37
+
38
+ # IDEs
39
+ .idea/
40
+ .vscode/
41
+ *.swp
42
+ *.swo
43
+ *~
44
+
45
+ # OS
46
+ .DS_Store
47
+ Thumbs.db
48
+
49
+ # Project-specific
50
+ *.log
51
+ .pi/
@@ -0,0 +1,155 @@
1
+ # pyocloop — code analysis
2
+
3
+ Analysis of `../pyocloop` @ `57d000d` (v0.3.1), focused on why the loop sometimes
4
+ hangs. Findings are marked **CONFIRMED** (verified against a live `opencode`
5
+ 1.18.7 server or by unambiguous code reading) or **PLAUSIBLE**.
6
+
7
+ ---
8
+
9
+ ## The hang: three independent mechanisms, all reachable
10
+
11
+ The loop's blocking point is `tui.py:446`:
12
+
13
+ ```python
14
+ while not self._idle_event.is_set():
15
+ try:
16
+ await asyncio.wait_for(self._idle_event.wait(), timeout=IDLE_POLL_INTERVAL)
17
+ except asyncio.TimeoutError:
18
+ session = await self._client.get_session(self._current_session_id)
19
+ if session.get("idle", False): # <-- never true
20
+ self._idle_event.set()
21
+ ```
22
+
23
+ This `while` has **no exit other than `_idle_event` being set**. There is no
24
+ iteration deadline, no max-retry, no bail-out. So any failure to observe the
25
+ idle signal is an unrecoverable hang, not a slow iteration. Three separate
26
+ things can cause that.
27
+
28
+ ### H1 — The polling fallback is a no-op **CONFIRMED**
29
+
30
+ `git log` shows commit `02e9008 fix(tui): add session-idle polling fallback`,
31
+ added precisely to rescue a missed `session.idle`. It cannot work: the OpenCode
32
+ session object has **no `idle` field**. Live response from `POST /session`:
33
+
34
+ ```json
35
+ {"id":"ses_055ec9c8effeOIYm5v2tGw2MEo","slug":"sunny-forest","projectID":"global",
36
+ "directory":"/tmp","path":"tmp","cost":0,"tokens":{...},"title":"New session - ...",
37
+ "version":"1.18.7","time":{"created":1785264825201,"updated":1785264825201}}
38
+ ```
39
+
40
+ `session.get("idle", False)` is therefore **always `False`**. The fallback polls
41
+ every 30s forever and never fires. The intended safety net does not exist.
42
+
43
+ ### H2 — The OpenCode server can deadlock on its own stdout **CONFIRMED (mechanism)**
44
+
45
+ `opencode_server.py` spawns the server with `stdout=PIPE, stderr=STDOUT`, then
46
+ reads the pipe *only* until the "listening" banner:
47
+
48
+ ```python
49
+ async for raw in self._proc.stdout:
50
+ if "opencode server listening" in line:
51
+ return m.group(1) # <-- loop exits; nothing ever reads again
52
+ ```
53
+
54
+ After that, **nothing drains the pipe for the rest of the run**. asyncio buffers
55
+ into a `StreamReader`, but its flow control calls `pause_reading()` at the 64 KiB
56
+ high-water mark; the OS pipe (another ~64 KiB) then fills, and the server
57
+ **blocks in `write()` and stops serving**. The client sees no further SSE events
58
+ → H1 → permanent hang.
59
+
60
+ This matches the reported symptom precisely: works fine, then hangs
61
+ *sometimes*, after an unpredictable amount of work. It is output-volume
62
+ dependent, so a chatty local-LLM provider (extra warnings, retries, token
63
+ diagnostics on stderr — which is merged into the same pipe) reaches the
64
+ threshold much faster than a quiet cloud provider. **This is the most likely
65
+ cause of the hangs you observed, and it is a pyocloop bug, not an LLM problem.**
66
+
67
+ ### H3 — SSE reconnect drops events permanently **CONFIRMED**
68
+
69
+ `subscribe_events` reconnects with backoff up to 30 s, but OpenCode's `/event`
70
+ stream has no replay and the client sends no `Last-Event-ID`. **Every event
71
+ emitted during the gap is lost forever.** If the lost event is `session.idle`,
72
+ the iteration hangs (H1 again). A clean disconnect sets `backoff = 5.0`, so
73
+ there is always at least a 5 s blind window per reconnect.
74
+
75
+ ### Why the existing escape hatch doesn't help
76
+
77
+ `_reload_plan` (line 322) can set `_idle_event`, but only when
78
+ `is_plan_complete()` — i.e. only on the very last iteration. A stall on
79
+ iteration 3 of 20 is never rescued.
80
+
81
+ ---
82
+
83
+ ## Other defects
84
+
85
+ ### D1 — "Retry" is dead **CONFIRMED**
86
+
87
+ Every error path in `_worker_loop` does `post_message(_LoopError(...)); return` —
88
+ the worker exits. `action_retry` only repaints the header to `READY`, and
89
+ `action_start_loop` then sets `_start_event`, which **no one is awaiting**. After
90
+ any loop error, `R` then `S` leaves the app looking ready but permanently dead.
91
+ Restarting the process is the only recovery.
92
+
93
+ ### D2 — A stale idle event ends the wrong iteration **CONFIRMED**
94
+
95
+ `on__session_idle` accepts the event when the session ID is *empty*:
96
+
97
+ ```python
98
+ if not msg.session_id or msg.session_id == self._current_session_id:
99
+ ```
100
+
101
+ `_idle_event.clear()` happens at line 420, *before* the prompt is sent. A late
102
+ `session.idle` from iteration N-1 arriving in that window terminates iteration N
103
+ instantly → the loop spins, creating sessions that do no work. The server is
104
+ also shared and unauthenticated on a fixed port, so another client's events can
105
+ land here too.
106
+
107
+ ### D3 — Errors don't stop the loop **CONFIRMED**
108
+
109
+ `on__session_error` sets `_state = STATE_ERROR` *and* sets `_idle_event`, so the
110
+ loop immediately starts the next iteration while the header reads `ERROR`. With
111
+ a persistently failing provider this becomes an unbounded hot loop hammering the
112
+ LLM. There is no error-streak circuit breaker.
113
+
114
+ ### D4 — No progress-stall detection **CONFIRMED**
115
+
116
+ If the model does not tick a checkbox (very common with smaller local models),
117
+ the loop re-runs the identical task forever. Nothing compares plan progress
118
+ across iterations. Externally this is indistinguishable from a hang.
119
+
120
+ ### D5 — Fixed port 4096 **CONFIRMED**
121
+
122
+ No fallback and no port-in-use detection; a second instance or a stale server
123
+ kills startup. Should use port 0 / auto-select.
124
+
125
+ ### D6 — Pause is confusing **CONFIRMED (UX)**
126
+
127
+ `Space` during `RUNNING` only sets a flag checked *after* the current iteration
128
+ completes. `PAUSING` can persist for many minutes with no indication that the
129
+ pause is pending rather than stuck.
130
+
131
+ ### D7 — Minor
132
+
133
+ - `on__session_created` resets `_total_tokens`, so the `tok:` field is
134
+ per-iteration despite being named total, and it fires for *any* session
135
+ created on the shared server.
136
+ - `parse_model_string("foo")` yields `providerID: ""` — likely rejected upstream.
137
+ - `_log_fh` is only closed in `_do_quit`; an exception path leaks it.
138
+ - Dead imports (`os`, `sys`, `signal` partially) in `opencode_client.py` /
139
+ `opencode_server.py`.
140
+ - Blocked tasks count against `percent_complete` forever, so the bar never
141
+ reaches 100% on a plan with blocked items.
142
+
143
+ ---
144
+
145
+ ## Verdict
146
+
147
+ Your instinct that the local LLM was to blame is understandable, but the
148
+ evidence points at pyocloop. **H2 (undrained stdout pipe) and H1 (a fallback
149
+ that can never fire) together produce exactly "it works, then it silently stops
150
+ forever"**, and the loop has no deadline that would ever break out. A stuck
151
+ local LLM would have produced the same visible symptom *only* because pyocloop
152
+ has no timeout to distinguish the two — which is itself the deeper flaw.
153
+
154
+ **Design lesson carried into piocloop: every wait gets a deadline, every pipe
155
+ gets a drainer, and lack of progress is a first-class detected state.**
@@ -0,0 +1,221 @@
1
+ # piocloop — design
2
+
3
+ A port of [pyocloop](../pyocloop) that drives the **PI coding agent**
4
+ (`@earendil-works/pi-coding-agent`, `pi` on PATH) instead of OpenCode.
5
+
6
+ Same product: a Textual TUI that repeatedly asks a coding agent to execute the
7
+ next task from `PLAN.md`, until the agent writes `<plan-complete>`.
8
+ Different — and much simpler — transport.
9
+
10
+ ---
11
+
12
+ ## 1. Why the PI port is structurally more reliable
13
+
14
+ pyocloop talks to an HTTP server over a network socket plus a lossy SSE stream.
15
+ PI offers **`--mode rpc`: newline-delimited JSON over the subprocess's own
16
+ stdin/stdout**. That deletes entire classes of the bugs found in `ANALYSIS.md`:
17
+
18
+ | pyocloop failure | piocloop |
19
+ |---|---|
20
+ | SSE reconnect gap loses `session.idle` (H3) | No socket. A pipe cannot silently drop a record; if it closes, the process died and we *know*. |
21
+ | Fixed port 4096 collisions (D5) | No port at all. |
22
+ | Ambiguous / cross-session idle events (D2) | One process, one conversation; `agent_settled` is unambiguous. |
23
+ | Polling fallback against a non-existent field (H1) | `get_state` returns a real `isStreaming` flag — a fallback that actually works. |
24
+ | Undrained stdout deadlocks the server (H2) | Still a live hazard — **must be designed against explicitly** (§4.1). |
25
+
26
+ Verified live against `pi` 0.82.1 (`--mode rpc --no-session -nt`), the event
27
+ sequence for one prompt is:
28
+
29
+ ```
30
+ response(prompt, success=true)
31
+ agent_start → turn_start → message_start → message_end → … → turn_end
32
+ agent_end
33
+ agent_settled ← the correct "iteration is over" signal
34
+ ```
35
+
36
+ **Use `agent_settled`, not `agent_end`.** Per the protocol docs, `agent_end` is
37
+ one low-level run and "may still be followed by retry, compaction, or queued
38
+ continuations"; `agent_settled` fires only once nothing more will happen
39
+ automatically. Treating `agent_end` as done would cut iterations short mid-retry.
40
+
41
+ ---
42
+
43
+ ## 2. Architecture
44
+
45
+ ```
46
+ cli.py typer entry point (piloop run / bootstrap)
47
+ tui.py Textual app: header, activity log, state machine, loop worker
48
+ pi_client.py spawns & owns `pi --mode rpc`; JSONL framing; request/response
49
+ correlation; event fan-out; extension-UI auto-responder
50
+ pi_events.py event → internal Message mapping (the _dispatch_sse equivalent)
51
+ plan_parser.py UNCHANGED — port verbatim from pyocloop
52
+ ```
53
+
54
+ `plan_parser.py` is agent-agnostic and correct; copy it as-is (plus tests, which
55
+ pyocloop lacks). `tui.py`'s widget/state-machine layer ports over largely intact.
56
+ `opencode_server.py` + `opencode_client.py` are **replaced wholesale** by
57
+ `pi_client.py`.
58
+
59
+ ### Process model
60
+
61
+ One long-lived `pi --mode rpc` process for the whole run; per iteration send
62
+ `{"type":"new_session"}` to get a fresh context. This is much cheaper than
63
+ respawning (PI's startup does model-catalog and extension loading — the smoke
64
+ test showed several hundred ms and 5 extension callbacks). The supervisor may
65
+ still kill and respawn the process as the escalation step of the watchdog (§4.2),
66
+ so respawn must be supported anyway.
67
+
68
+ ### Session-per-iteration rationale
69
+
70
+ Same as pyocloop: each iteration starts from a clean context so the agent
71
+ re-reads `PLAN.md` rather than trusting stale in-context state. `--session-dir`
72
+ is passed through so sessions remain inspectable/resumable after a run.
73
+
74
+ ---
75
+
76
+ ## 3. Command / event mapping
77
+
78
+ | Need | RPC |
79
+ |---|---|
80
+ | new iteration | `{"type":"new_session"}` |
81
+ | send loop prompt | `{"id":"…","type":"prompt","message":"…"}` |
82
+ | iteration finished | event `agent_settled` |
83
+ | cancel current work | `{"type":"abort"}` |
84
+ | liveness / stuck check | `{"type":"get_state"}` → `data.isStreaming` |
85
+ | model at startup | CLI `--model provider/id`, or `{"type":"set_model",…}` |
86
+ | token/cost display | `turn_end` / `message_end` message objects |
87
+ | activity log | `tool_execution_start`, `message_update` text deltas |
88
+ | plan-file edit detection | `tool_execution_end` where the edited path == plan file |
89
+
90
+ Note `file.edited` has no direct PI equivalent — piocloop watches
91
+ `tool_execution_end` for edit/write tool calls, and in any case already re-reads
92
+ `PLAN.md` on a 4 s timer.
93
+
94
+ ### CLI surface
95
+
96
+ ```
97
+ piloop run [OPTIONS]
98
+ -m, --model TEXT provider/id, e.g. zai/glm-5.2 (pass through to pi)
99
+ --thinking TEXT off|minimal|low|medium|high|xhigh|max
100
+ --prompt PATH loop prompt [default: .loop-prompt.md]
101
+ --plan PATH plan file [default: PLAN.md]
102
+ -r, --run start immediately
103
+ --max-iterations N hard stop [default: 100]
104
+ --iteration-timeout S per-iteration deadline [default: 1800]
105
+ --session-dir PATH pass through to pi
106
+ --tools / --exclude-tools TEXT pass through
107
+ --dialog-policy TEXT cancel|allow|deny [default: cancel] (§4.3)
108
+ --verbose, --log PATH, --debug
109
+ ```
110
+
111
+ `-p/--port` is **dropped** (no server). pyocloop's `-a/--agent` has no PI
112
+ equivalent — PI has no named-agent concept; the nearest equivalents are
113
+ `--append-system-prompt` and `--skill`, so expose those instead rather than
114
+ faking `--agent`.
115
+
116
+ ---
117
+
118
+ ## 4. The three things that must not be repeated
119
+
120
+ Every hang in `ANALYSIS.md` traces to a wait with no deadline or a pipe with no
121
+ reader. These are the load-bearing requirements of the port.
122
+
123
+ ### 4.1 Drain every pipe, always
124
+
125
+ `pi`'s stdout is the protocol, so it is read continuously by construction —
126
+ but **stderr must get its own concurrent drain task**. pyocloop's H2 deadlock
127
+ came from exactly this. Never merge stderr into the protocol stream (it would
128
+ corrupt JSONL framing); read it separately into the log ring buffer.
129
+
130
+ **Framing rule from the spec:** split on `\n` only, stripping an optional
131
+ trailing `\r`. Do *not* use a generic line reader — Python's `for line in
132
+ stream` splits only on `\n` for text streams and is fine, but note the docs
133
+ explicitly call out Node's `readline` as non-compliant because it also breaks on
134
+ `U+2028`/`U+2029`, which are legal inside JSON strings. Use
135
+ `asyncio.StreamReader.readuntil(b"\n")` on the **binary** stream and decode
136
+ after splitting, so multi-byte and separator characters can never desync frames.
137
+ Raise `readuntil`'s limit well above the default 64 KiB — `message_update`
138
+ events embed the full partial message and routinely exceed it.
139
+
140
+ ### 4.2 Every wait has a deadline, and a defined escalation
141
+
142
+ Replace pyocloop's unbounded `while not self._idle_event.is_set()` with a
143
+ three-stage watchdog:
144
+
145
+ 1. **Soft poll** — every 30 s of silence, send `get_state`. If
146
+ `isStreaming` is `false` and no `agent_settled` arrived, the settle signal was
147
+ missed: treat as settled. *(This is the fallback pyocloop intended; unlike
148
+ `session.idle`, `isStreaming` genuinely exists.)*
149
+ 2. **Iteration deadline** — at `--iteration-timeout` (default 30 min), send
150
+ `abort`, log it, and wait a short grace period for settle.
151
+ 3. **Process wedge** — if abort doesn't settle within ~30 s, or the pipe hits
152
+ EOF, or `get_state` stops responding: kill the process group, respawn, and
153
+ resume the loop at the next iteration. Count consecutive respawns; stop after
154
+ 3 with a clear error.
155
+
156
+ Combined with `--max-iterations`, **no code path can wait forever**.
157
+
158
+ ### 4.3 Answer extension UI dialogs — or the loop will hang
159
+
160
+ This is a genuine new hazard and the single most important protocol detail for
161
+ an *unattended* harness. Extensions can call `ctx.ui.select()` / `confirm()` /
162
+ `input()` / `editor()`, which emit `extension_ui_request` on stdout and
163
+ **block the agent until the client sends a matching `extension_ui_response` on
164
+ stdin**. Only dialogs carrying a `timeout` field auto-resolve; the rest wait
165
+ indefinitely. A TUI harness that ignores them reproduces pyocloop's hang with a
166
+ new cause.
167
+
168
+ The smoke test on this machine already emitted five `extension_ui_request`s per
169
+ prompt (`setStatus` from the `openrouter`, `zai-usage` and `mcp` extensions).
170
+ Those are fire-and-forget — but the same channel carries blocking dialogs, and
171
+ this user has six extension packages installed.
172
+
173
+ **Requirement:** `pi_client.py` MUST implement the responder:
174
+
175
+ - fire-and-forget (`setStatus`, `notify`, `setWidget`, `setTitle`,
176
+ `set_editor_text`) → render into the header/activity log, send nothing;
177
+ - blocking (`select`, `confirm`, `input`, `editor`) → reply immediately per
178
+ `--dialog-policy`, default `cancel`
179
+ (`{"type":"extension_ui_response","id":…,"cancelled":true}`), and log it
180
+ prominently so a silently-declined permission prompt is visible.
181
+
182
+ Status text arrives with ANSI SGR escapes embedded (observed:
183
+ `Z.ai:…`), so strip or translate them before writing to
184
+ the Textual log.
185
+
186
+ ### 4.4 Progress stalls are a state, not a hang
187
+
188
+ Carried over from D4: track `PlanProgress.completed` across iterations. If it
189
+ does not increase for N consecutive iterations (default 3), enter a distinct
190
+ `STALLED` state, pause the loop, and say so. This is the difference between "the
191
+ model is looping on a task it can't finish" and "the harness is broken" — a
192
+ distinction pyocloop cannot make.
193
+
194
+ ### 4.5 Recoverable errors must actually recover
195
+
196
+ Fix D1/D3 structurally: `_worker_loop` never `return`s on error. It catches,
197
+ increments an error streak, applies backoff, and continues; only
198
+ `--max-iterations`, a completed plan, an error streak of 3, or an explicit stop
199
+ ends the worker. `Retry` re-arms a *live* worker rather than pretending to
200
+ restart a dead one.
201
+
202
+ ---
203
+
204
+ ## 5. Deliberate non-goals for v0.1
205
+
206
+ - No PI *SDK* embedding (`AgentSession`) — that is Node-only; the subprocess RPC
207
+ boundary is the whole point of a Python harness.
208
+ - No multi-agent / parallel task execution.
209
+ - No resume-mid-plan-from-session — `PLAN.md` is already the durable state.
210
+
211
+ ---
212
+
213
+ ## 6. Risks
214
+
215
+ | Risk | Mitigation |
216
+ |---|---|
217
+ | Blocking extension dialog stalls unattended runs | §4.3 auto-responder, default `cancel` |
218
+ | PI RPC protocol churn (0.82.x, pre-1.0) | Isolate all protocol knowledge in `pi_client.py`; pin a tested `pi` version range and assert it at startup |
219
+ | `message_update` flood (one event per token) overwhelms the TUI | Coalesce text deltas; render at most ~10 Hz; never log deltas individually |
220
+ | Local/small models never tick checkboxes | §4.4 stall detection makes it visible instead of silent |
221
+ | Project-trust prompts skipped in non-interactive mode change tool availability | Document `--approve`/`-a`; surface the effective trust state at startup |
piocloop-0.1.0/LICENSE ADDED
@@ -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.
@@ -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