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.
- piocloop-0.1.0/.github/workflows/publish.yml +34 -0
- piocloop-0.1.0/.gitignore +51 -0
- piocloop-0.1.0/ANALYSIS.md +155 -0
- piocloop-0.1.0/DESIGN.md +221 -0
- piocloop-0.1.0/LICENSE +21 -0
- piocloop-0.1.0/PKG-INFO +191 -0
- piocloop-0.1.0/PLAN.md +81 -0
- piocloop-0.1.0/README.md +164 -0
- piocloop-0.1.0/pyproject.toml +50 -0
- piocloop-0.1.0/skills/README.md +79 -0
- piocloop-0.1.0/skills/piocloop-setup/SKILL.md +160 -0
- piocloop-0.1.0/skills/piocloop-setup/assets/PLAN.template.md +50 -0
- piocloop-0.1.0/skills/piocloop-setup/assets/loop-prompt.template.md +40 -0
- piocloop-0.1.0/skills/piocloop-setup/references/PLAN-FORMAT.md +95 -0
- piocloop-0.1.0/skills/piocloop-setup/references/PROMPT-AUTHORING.md +87 -0
- piocloop-0.1.0/src/piocloop/__init__.py +1 -0
- piocloop-0.1.0/src/piocloop/__main__.py +3 -0
- piocloop-0.1.0/src/piocloop/cli.py +230 -0
- piocloop-0.1.0/src/piocloop/loop.py +487 -0
- piocloop-0.1.0/src/piocloop/pi_client.py +441 -0
- piocloop-0.1.0/src/piocloop/pi_events.py +326 -0
- piocloop-0.1.0/src/piocloop/plan_parser.py +140 -0
- piocloop-0.1.0/src/piocloop/tui.py +289 -0
- piocloop-0.1.0/tests/fake_pi.py +242 -0
- piocloop-0.1.0/tests/test_loop.py +289 -0
- piocloop-0.1.0/tests/test_pi_client.py +281 -0
- piocloop-0.1.0/tests/test_pi_events.py +348 -0
- piocloop-0.1.0/tests/test_plan_parser.py +170 -0
- piocloop-0.1.0/tests/test_skill_assets.py +163 -0
- piocloop-0.1.0/tests/test_tui.py +111 -0
|
@@ -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.**
|
piocloop-0.1.0/DESIGN.md
ADDED
|
@@ -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
|
+
`[38;2;128;128;128mZ.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.
|
piocloop-0.1.0/PKG-INFO
ADDED
|
@@ -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
|