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/pi_client.py ADDED
@@ -0,0 +1,441 @@
1
+ """Client for the PI coding agent's RPC mode (`pi --mode rpc`).
2
+
3
+ All knowledge of the PI wire protocol lives in this module; the rest of piocloop
4
+ sees only `PiClient` methods and dict events.
5
+
6
+ Three design rules here are load-bearing (see DESIGN.md §4). They exist because
7
+ pyocloop hung in production for want of each one:
8
+
9
+ 1. Every pipe is drained continuously. An unread stderr pipe fills and blocks
10
+ the child process — that was pyocloop's H2 deadlock.
11
+ 2. Every wait has a deadline. No `await` in this module can block forever.
12
+ 3. Blocking extension UI dialogs are answered automatically. `pi` stops and
13
+ waits for a reply to `select`/`confirm`/`input`/`editor`, and only dialogs
14
+ carrying a `timeout` field ever self-resolve, so an unattended harness that
15
+ ignores them hangs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import collections
22
+ import contextlib
23
+ import json
24
+ import os
25
+ import re
26
+ import signal
27
+ from typing import Any, Awaitable, Callable, Optional, Sequence
28
+
29
+ # Dialog methods block the agent until we answer. Everything else on the
30
+ # extension UI channel is fire-and-forget and must NOT be answered.
31
+ _DIALOG_METHODS = frozenset({"select", "confirm", "input", "editor"})
32
+
33
+ # Guard against a pathological frame eating all memory. Real `message_update`
34
+ # events embed the full partial message and can be large, so this is generous.
35
+ _MAX_FRAME_BYTES = 64 * 1024 * 1024
36
+
37
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
38
+
39
+ # Synthetic event types, emitted by this module rather than by `pi`.
40
+ EVENT_PROCESS_EXITED = "_process_exited"
41
+ EVENT_STDERR = "_stderr"
42
+ EVENT_PROTOCOL_ERROR = "_protocol_error"
43
+ EVENT_DIALOG_ANSWERED = "_dialog_answered"
44
+
45
+
46
+ def strip_ansi(text: str) -> str:
47
+ """Remove SGR escapes — extension status text arrives pre-coloured."""
48
+ return _ANSI_RE.sub("", text)
49
+
50
+
51
+ class PiError(RuntimeError):
52
+ """A command was rejected by `pi`, or the process is unusable."""
53
+
54
+
55
+ class PiTimeout(PiError):
56
+ """A command was not answered within its deadline."""
57
+
58
+
59
+ class PiExited(PiError):
60
+ """The `pi` process exited while a command was in flight."""
61
+
62
+
63
+ def build_argv(
64
+ *,
65
+ pi_bin: str = "pi",
66
+ model: Optional[str] = None,
67
+ thinking: Optional[str] = None,
68
+ session_dir: Optional[str] = None,
69
+ no_session: bool = False,
70
+ tools: Optional[str] = None,
71
+ exclude_tools: Optional[str] = None,
72
+ append_system_prompt: Sequence[str] = (),
73
+ skills: Sequence[str] = (),
74
+ approve: Optional[bool] = None,
75
+ extra_args: Sequence[str] = (),
76
+ ) -> list[str]:
77
+ """Build the `pi --mode rpc` command line."""
78
+ argv = [pi_bin, "--mode", "rpc"]
79
+ if model:
80
+ argv += ["--model", model]
81
+ if thinking:
82
+ argv += ["--thinking", thinking]
83
+ if session_dir:
84
+ argv += ["--session-dir", session_dir]
85
+ if no_session:
86
+ argv += ["--no-session"]
87
+ if tools:
88
+ argv += ["--tools", tools]
89
+ if exclude_tools:
90
+ argv += ["--exclude-tools", exclude_tools]
91
+ for text in append_system_prompt:
92
+ argv += ["--append-system-prompt", text]
93
+ for skill in skills:
94
+ argv += ["--skill", skill]
95
+ if approve is True:
96
+ argv += ["--approve"]
97
+ elif approve is False:
98
+ argv += ["--no-approve"]
99
+ argv += list(extra_args)
100
+ return argv
101
+
102
+
103
+ class PiClient:
104
+ """Owns a `pi --mode rpc` subprocess and speaks its JSONL protocol.
105
+
106
+ Events (including synthetic ones) are pushed to `events`; the caller is
107
+ expected to consume that queue continuously.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ argv: Sequence[str],
113
+ *,
114
+ cwd: Optional[str] = None,
115
+ dialog_policy: str = "cancel",
116
+ stderr_tail: int = 200,
117
+ default_timeout: float = 60.0,
118
+ ) -> None:
119
+ self._argv = list(argv)
120
+ self._cwd = cwd
121
+ self._dialog_policy = dialog_policy
122
+ self._default_timeout = default_timeout
123
+
124
+ self._proc: Optional[asyncio.subprocess.Process] = None
125
+ self._tasks: list[asyncio.Task] = []
126
+ self._pending: dict[str, asyncio.Future] = {}
127
+ self._next_id = 0
128
+ self._write_lock = asyncio.Lock()
129
+ self._closing = False
130
+
131
+ self.events: asyncio.Queue[dict] = asyncio.Queue()
132
+ self.exited: asyncio.Event = asyncio.Event()
133
+ self.returncode: Optional[int] = None
134
+ self.stderr_tail: collections.deque[str] = collections.deque(maxlen=stderr_tail)
135
+
136
+ # ------------------------------------------------------------------
137
+ # Lifecycle
138
+ # ------------------------------------------------------------------
139
+
140
+ async def start(self) -> None:
141
+ if self._proc is not None:
142
+ raise PiError("PiClient already started")
143
+ try:
144
+ self._proc = await asyncio.create_subprocess_exec(
145
+ *self._argv,
146
+ cwd=self._cwd,
147
+ stdin=asyncio.subprocess.PIPE,
148
+ stdout=asyncio.subprocess.PIPE,
149
+ stderr=asyncio.subprocess.PIPE,
150
+ start_new_session=True, # own process group, so close() kills children
151
+ )
152
+ except FileNotFoundError as exc:
153
+ raise PiError(f"Could not execute {self._argv[0]!r}: {exc}") from exc
154
+
155
+ # Both pipes get a dedicated reader for the whole process lifetime.
156
+ # Leaving either unread eventually blocks the child (pyocloop bug H2).
157
+ self._tasks = [
158
+ asyncio.create_task(self._read_stdout(), name="pi-stdout"),
159
+ asyncio.create_task(self._read_stderr(), name="pi-stderr"),
160
+ ]
161
+
162
+ async def close(self, timeout: float = 5.0) -> None:
163
+ """Shut the process down. Idempotent, and safe to call after a crash."""
164
+ self._closing = True
165
+ proc, self._proc = self._proc, None
166
+ if proc is not None:
167
+ with contextlib.suppress(Exception):
168
+ if proc.stdin and not proc.stdin.is_closing():
169
+ proc.stdin.close()
170
+ try:
171
+ await asyncio.wait_for(proc.wait(), timeout=timeout)
172
+ except (asyncio.TimeoutError, Exception): # noqa: B014 - best effort
173
+ self._kill_group(proc, signal.SIGTERM)
174
+ try:
175
+ await asyncio.wait_for(proc.wait(), timeout=timeout)
176
+ except (asyncio.TimeoutError, Exception): # noqa: B014
177
+ self._kill_group(proc, signal.SIGKILL)
178
+ with contextlib.suppress(Exception):
179
+ await proc.wait()
180
+ self.returncode = proc.returncode
181
+
182
+ for task in self._tasks:
183
+ task.cancel()
184
+ for task in self._tasks:
185
+ with contextlib.suppress(asyncio.CancelledError, Exception):
186
+ await task
187
+ self._tasks = []
188
+ self._fail_pending(PiExited("pi process closed"))
189
+ self.exited.set()
190
+
191
+ @staticmethod
192
+ def _kill_group(proc: asyncio.subprocess.Process, sig: int) -> None:
193
+ with contextlib.suppress(ProcessLookupError, OSError):
194
+ os.killpg(os.getpgid(proc.pid), sig)
195
+
196
+ # ------------------------------------------------------------------
197
+ # Readers
198
+ # ------------------------------------------------------------------
199
+
200
+ async def _read_stdout(self) -> None:
201
+ """Parse strict JSONL: records are delimited by LF and nothing else.
202
+
203
+ Deliberately not using readline()/readuntil(): we split the raw byte
204
+ stream on b"\\n" ourselves so that neither a stream-buffer limit nor a
205
+ Unicode line separator (U+2028/U+2029, legal inside JSON strings) can
206
+ desynchronise framing.
207
+ """
208
+ assert self._proc and self._proc.stdout
209
+ buf = bytearray()
210
+ try:
211
+ while True:
212
+ chunk = await self._proc.stdout.read(65536)
213
+ if not chunk:
214
+ break
215
+ buf.extend(chunk)
216
+ if len(buf) > _MAX_FRAME_BYTES:
217
+ raise PiError("RPC frame exceeded maximum size")
218
+ while True:
219
+ nl = buf.find(b"\n")
220
+ if nl == -1:
221
+ break
222
+ raw = bytes(buf[:nl])
223
+ del buf[: nl + 1]
224
+ self._handle_frame(raw)
225
+ except asyncio.CancelledError:
226
+ raise
227
+ except Exception as exc:
228
+ self._emit({"type": EVENT_PROTOCOL_ERROR, "error": str(exc)})
229
+ finally:
230
+ await self._on_eof()
231
+
232
+ def _handle_frame(self, raw: bytes) -> None:
233
+ line = raw.decode("utf-8", errors="replace").rstrip("\r")
234
+ if not line.strip():
235
+ return
236
+ try:
237
+ msg = json.loads(line)
238
+ except json.JSONDecodeError as exc:
239
+ # Never fatal: a malformed frame must not take the loop down.
240
+ self._emit({"type": EVENT_PROTOCOL_ERROR, "error": f"bad JSON: {exc}", "raw": line[:500]})
241
+ return
242
+ if not isinstance(msg, dict):
243
+ self._emit({"type": EVENT_PROTOCOL_ERROR, "error": "frame was not an object", "raw": line[:500]})
244
+ return
245
+
246
+ mtype = msg.get("type")
247
+ if mtype == "response":
248
+ self._resolve(msg)
249
+ elif mtype == "extension_ui_request":
250
+ self._handle_ui_request(msg)
251
+ else:
252
+ self._emit(msg)
253
+
254
+ async def _read_stderr(self) -> None:
255
+ assert self._proc and self._proc.stderr
256
+ try:
257
+ while True:
258
+ chunk = await self._proc.stderr.read(65536)
259
+ if not chunk:
260
+ break
261
+ for line in chunk.decode("utf-8", errors="replace").splitlines():
262
+ line = line.rstrip()
263
+ if line:
264
+ self.stderr_tail.append(line)
265
+ self._emit({"type": EVENT_STDERR, "text": strip_ansi(line)})
266
+ except asyncio.CancelledError:
267
+ raise
268
+ except Exception:
269
+ pass
270
+
271
+ async def _on_eof(self) -> None:
272
+ """stdout closed — the process is gone or going."""
273
+ if self.exited.is_set():
274
+ return
275
+ proc = self._proc
276
+ if proc is not None:
277
+ with contextlib.suppress(Exception):
278
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
279
+ self.returncode = proc.returncode
280
+ self.exited.set()
281
+ self._fail_pending(PiExited(f"pi exited (code {self.returncode})"))
282
+ if not self._closing:
283
+ self._emit({
284
+ "type": EVENT_PROCESS_EXITED,
285
+ "returncode": self.returncode,
286
+ "stderr_tail": list(self.stderr_tail)[-20:],
287
+ })
288
+
289
+ def _emit(self, event: dict) -> None:
290
+ self.events.put_nowait(event)
291
+
292
+ # ------------------------------------------------------------------
293
+ # Extension UI auto-responder (DESIGN §4.3)
294
+ # ------------------------------------------------------------------
295
+
296
+ def _handle_ui_request(self, msg: dict) -> None:
297
+ method = msg.get("method", "")
298
+ if method not in _DIALOG_METHODS:
299
+ # Fire-and-forget (setStatus/notify/setWidget/setTitle/...). Surface
300
+ # it, but sending a response would be a protocol violation.
301
+ self._emit({
302
+ "type": "extension_ui_info",
303
+ "method": method,
304
+ "text": strip_ansi(str(
305
+ msg.get("statusText") or msg.get("message") or msg.get("title") or ""
306
+ )),
307
+ "raw": msg,
308
+ })
309
+ return
310
+
311
+ reply = self._dialog_reply(msg)
312
+ # Answer immediately: `pi` is blocked until we do.
313
+ asyncio.create_task(self._send_raw(reply))
314
+ self._emit({
315
+ "type": EVENT_DIALOG_ANSWERED,
316
+ "method": method,
317
+ "title": strip_ansi(str(msg.get("title") or "")),
318
+ "policy": self._dialog_policy,
319
+ "reply": reply,
320
+ })
321
+
322
+ def _dialog_reply(self, msg: dict) -> dict:
323
+ rid = msg.get("id")
324
+ method = msg.get("method")
325
+ policy = self._dialog_policy
326
+
327
+ if policy == "cancel":
328
+ return {"type": "extension_ui_response", "id": rid, "cancelled": True}
329
+
330
+ if method == "confirm":
331
+ return {"type": "extension_ui_response", "id": rid, "confirmed": policy == "allow"}
332
+
333
+ if method == "select":
334
+ options = msg.get("options") or []
335
+ if policy == "allow" and options:
336
+ return {"type": "extension_ui_response", "id": rid, "value": options[0]}
337
+ return {"type": "extension_ui_response", "id": rid, "cancelled": True}
338
+
339
+ # input / editor have no meaningful non-interactive answer.
340
+ return {"type": "extension_ui_response", "id": rid, "cancelled": True}
341
+
342
+ # ------------------------------------------------------------------
343
+ # Command plumbing
344
+ # ------------------------------------------------------------------
345
+
346
+ async def _send_raw(self, payload: dict) -> None:
347
+ proc = self._proc
348
+ if proc is None or proc.stdin is None or self.exited.is_set():
349
+ raise PiExited("pi process is not running")
350
+ data = (json.dumps(payload) + "\n").encode("utf-8")
351
+ async with self._write_lock:
352
+ try:
353
+ proc.stdin.write(data)
354
+ await proc.stdin.drain()
355
+ except (BrokenPipeError, ConnectionResetError) as exc:
356
+ raise PiExited(f"pi stdin closed: {exc}") from exc
357
+
358
+ async def request(
359
+ self,
360
+ command: dict,
361
+ *,
362
+ timeout: Optional[float] = None,
363
+ ) -> dict:
364
+ """Send a command and await its correlated response.
365
+
366
+ Always bounded: raises PiTimeout rather than waiting indefinitely.
367
+ """
368
+ self._next_id += 1
369
+ rid = f"piloop-{self._next_id}"
370
+ payload = {**command, "id": rid}
371
+
372
+ loop = asyncio.get_running_loop()
373
+ future: asyncio.Future = loop.create_future()
374
+ self._pending[rid] = future
375
+ try:
376
+ await self._send_raw(payload)
377
+ data = await asyncio.wait_for(
378
+ future, timeout=timeout if timeout is not None else self._default_timeout
379
+ )
380
+ except asyncio.TimeoutError as exc:
381
+ raise PiTimeout(f"no response to {command.get('type')!r} within deadline") from exc
382
+ finally:
383
+ self._pending.pop(rid, None)
384
+
385
+ if not data.get("success", False):
386
+ raise PiError(f"{command.get('type')} failed: {data.get('error', 'unknown error')}")
387
+ return data.get("data") or {}
388
+
389
+ def _resolve(self, msg: dict) -> None:
390
+ rid = msg.get("id")
391
+ future = self._pending.pop(rid, None) if rid else None
392
+ if future is None:
393
+ # Uncorrelated response (e.g. a parse error for an unparseable
394
+ # command). Surface it rather than dropping it silently.
395
+ self._emit({"type": "uncorrelated_response", "raw": msg})
396
+ return
397
+ if not future.done():
398
+ future.set_result(msg)
399
+
400
+ def _fail_pending(self, exc: Exception) -> None:
401
+ for future in self._pending.values():
402
+ if not future.done():
403
+ future.set_exception(exc)
404
+ self._pending.clear()
405
+
406
+ # ------------------------------------------------------------------
407
+ # Commands
408
+ # ------------------------------------------------------------------
409
+
410
+ async def new_session(self, timeout: float = 30.0) -> dict:
411
+ return await self.request({"type": "new_session"}, timeout=timeout)
412
+
413
+ async def prompt(self, message: str, *, timeout: float = 60.0) -> dict:
414
+ """Send a prompt. The response only acknowledges acceptance.
415
+
416
+ Completion is signalled later by the `agent_settled` event.
417
+ """
418
+ return await self.request({"type": "prompt", "message": message}, timeout=timeout)
419
+
420
+ async def abort(self, timeout: float = 30.0) -> dict:
421
+ return await self.request({"type": "abort"}, timeout=timeout)
422
+
423
+ async def get_state(self, timeout: float = 30.0) -> dict:
424
+ return await self.request({"type": "get_state"}, timeout=timeout)
425
+
426
+ async def set_model(self, provider: str, model_id: str, timeout: float = 30.0) -> dict:
427
+ return await self.request(
428
+ {"type": "set_model", "provider": provider, "modelId": model_id},
429
+ timeout=timeout,
430
+ )
431
+
432
+ # ------------------------------------------------------------------
433
+ # Context manager
434
+ # ------------------------------------------------------------------
435
+
436
+ async def __aenter__(self) -> "PiClient":
437
+ await self.start()
438
+ return self
439
+
440
+ async def __aexit__(self, *_: object) -> None:
441
+ await self.close()