pi-py-sdk 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.
@@ -0,0 +1,8 @@
1
+ """pi_py_agent: a Python coding agent built on the pi-py-sdk RPC bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import run_once, run_repl
6
+ from .render import Renderer
7
+
8
+ __all__ = ["run_repl", "run_once", "Renderer"]
pi_py_agent/app.py ADDED
@@ -0,0 +1,337 @@
1
+ """Interactive REPL and one-shot runner for the Python coding agent.
2
+
3
+ Built entirely on :class:`pi_py_sdk.PiAgent` — the agent loop, tools, and model calls
4
+ all run inside Pi via the RPC bridge.
5
+
6
+ The REPL multiplexes a single stdin source across three consumers — idle prompts,
7
+ mid-turn steering, and approval dialogs — using one persistent reader thread feeding a
8
+ single async dispatch loop. A turn runs as a background task so the loop stays free to
9
+ route steering/abort input while the agent streams. SIGINT aborts the active turn.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import signal
16
+ import sys
17
+ import threading
18
+ from typing import Any
19
+
20
+ from pi_py_sdk import ExtensionUIRequest, PiAgent, PiConfig, PiTimeoutError
21
+
22
+ from .render import Renderer
23
+
24
+ _HELP = """\
25
+ Commands (when idle):
26
+ /help show this help
27
+ /model | /models show or list models
28
+ /new start a fresh session
29
+ /state model, message count, queue modes
30
+ /compact compact the conversation now
31
+ /fork [n] list forkable messages, or fork message n
32
+ /clone clone the current branch into a new session
33
+ /exit | /quit leave
34
+
35
+ While the agent is responding, type to steer it:
36
+ <text> steer (delivered after the current tool call)
37
+ +<text> queue a follow-up (delivered after the turn)
38
+ /abort stop the current turn
39
+ Ctrl-C aborts the active turn; Ctrl-D (EOF) exits."""
40
+
41
+ _YES = {"y", "yes"}
42
+
43
+
44
+ def classify_turn_input(line: str) -> tuple[str, str]:
45
+ """Classify a line typed during an active turn into (action, text)."""
46
+ if line in ("/exit", "/quit"):
47
+ return ("exit", "")
48
+ if line in ("/abort", "/stop"):
49
+ return ("abort", "")
50
+ if line.startswith("+"):
51
+ return ("follow_up", line[1:].strip())
52
+ return ("steer", line)
53
+
54
+
55
+ def parse_approval(request: ExtensionUIRequest, line: str) -> Any:
56
+ """Map a typed line to the value the SDK expects for a dialog request."""
57
+ method = request.method
58
+ if method == "confirm":
59
+ return line.strip().lower() in _YES
60
+ if method == "select":
61
+ options = request.options or []
62
+ stripped = line.strip()
63
+ if stripped.isdigit() and int(stripped) < len(options):
64
+ return options[int(stripped)]
65
+ return None
66
+ if method in ("input", "editor"):
67
+ return line if line else None
68
+ return None
69
+
70
+
71
+ class LineReader:
72
+ """Reads stdin lines on a daemon thread into an asyncio queue.
73
+
74
+ A daemon thread (not the loop executor) is used so a blocked ``readline`` never
75
+ prevents interpreter exit. EOF enqueues a ``None`` sentinel.
76
+ """
77
+
78
+ def __init__(self) -> None:
79
+ self.queue: asyncio.Queue[str | None] = asyncio.Queue()
80
+ self._loop: asyncio.AbstractEventLoop | None = None
81
+ self._thread: threading.Thread | None = None
82
+
83
+ def start(self) -> None:
84
+ self._loop = asyncio.get_event_loop()
85
+ self._thread = threading.Thread(target=self._read_loop, daemon=True)
86
+ self._thread.start()
87
+
88
+ def _read_loop(self) -> None:
89
+ while True:
90
+ line = sys.stdin.readline()
91
+ assert self._loop is not None
92
+ if line == "": # EOF
93
+ self._loop.call_soon_threadsafe(self.queue.put_nowait, None)
94
+ return
95
+ self._loop.call_soon_threadsafe(self.queue.put_nowait, line.rstrip("\n"))
96
+
97
+ async def get(self) -> str | None:
98
+ return await self.queue.get()
99
+
100
+
101
+ class Repl:
102
+ def __init__(self, agent: PiAgent, *, color: bool = True) -> None:
103
+ self.agent = agent
104
+ self.renderer = Renderer(color=color)
105
+ self.color = color
106
+ self.reader = LineReader()
107
+ self._loop: asyncio.AbstractEventLoop | None = None
108
+ self._turn_task: asyncio.Task[None] | None = None
109
+ self._pending_approval: tuple[ExtensionUIRequest, asyncio.Future[Any]] | None = None
110
+ self._fork_list: list[dict[str, Any]] = []
111
+ self._exit = False
112
+
113
+ # -- output helpers ---------------------------------------------------
114
+
115
+ def _out(self, text: str) -> None:
116
+ sys.stdout.write(text)
117
+ sys.stdout.flush()
118
+
119
+ def _idle_prompt(self) -> None:
120
+ if not self._exit:
121
+ self._out("\nYou: ")
122
+
123
+ def _turn_active(self) -> bool:
124
+ return self._turn_task is not None and not self._turn_task.done()
125
+
126
+ # -- lifecycle --------------------------------------------------------
127
+
128
+ async def run(self, initial: str | None = None) -> None:
129
+ self._loop = asyncio.get_event_loop()
130
+ self.reader.start()
131
+ self.agent.on_ui_request(self._approve)
132
+ self._install_sigint()
133
+ self._out(f"pi-py · {await self._model_label()} · /help for commands\n")
134
+
135
+ if initial:
136
+ self._out(f"You: {initial}\n")
137
+ self._start_turn(initial)
138
+ else:
139
+ self._idle_prompt()
140
+
141
+ try:
142
+ while not self._exit:
143
+ line = await self.reader.get()
144
+ if line is None: # EOF
145
+ break
146
+ await self._dispatch(line)
147
+ finally:
148
+ self._teardown()
149
+
150
+ def _install_sigint(self) -> None:
151
+ try:
152
+ assert self._loop is not None
153
+ self._loop.add_signal_handler(signal.SIGINT, self._on_sigint)
154
+ except (NotImplementedError, RuntimeError):
155
+ pass # e.g. Windows / non-main thread — fall back to default behavior
156
+
157
+ def _on_sigint(self) -> None:
158
+ if self._turn_active():
159
+ self._out("\n[aborting]\n")
160
+ asyncio.ensure_future(self.agent.abort())
161
+ else:
162
+ self._exit = True
163
+ self.reader.queue.put_nowait(None) # unblock the dispatch loop
164
+
165
+ def _teardown(self) -> None:
166
+ if self._turn_task and not self._turn_task.done():
167
+ self._turn_task.cancel()
168
+ if self._pending_approval and not self._pending_approval[1].done():
169
+ self._pending_approval[1].set_result(None)
170
+
171
+ # -- input dispatch ---------------------------------------------------
172
+
173
+ async def _dispatch(self, line: str) -> None:
174
+ # 1) An open approval dialog claims the next line.
175
+ if self._pending_approval is not None:
176
+ request, future = self._pending_approval
177
+ if not future.done():
178
+ future.set_result(parse_approval(request, line))
179
+ return
180
+
181
+ # 2) During a turn, input steers the agent.
182
+ if self._turn_active():
183
+ await self._route_steering(line)
184
+ return
185
+
186
+ # 3) Idle: blank, command, or a new prompt.
187
+ stripped = line.strip()
188
+ if not stripped:
189
+ self._idle_prompt()
190
+ return
191
+ if stripped.startswith("/"):
192
+ if await self._command(stripped):
193
+ self._exit = True
194
+ return
195
+ self._idle_prompt()
196
+ return
197
+ self._start_turn(stripped)
198
+
199
+ async def _route_steering(self, line: str) -> None:
200
+ stripped = line.strip()
201
+ if not stripped:
202
+ return
203
+ action, text = classify_turn_input(stripped)
204
+ if action == "exit":
205
+ await self.agent.abort()
206
+ self._exit = True
207
+ elif action == "abort":
208
+ await self.agent.abort()
209
+ self._out("[aborting]\n")
210
+ elif action == "follow_up" and text:
211
+ await self.agent.follow_up(text)
212
+ self._out(f"[queued follow-up: {text}]\n")
213
+ elif action == "steer":
214
+ await self.agent.steer(text)
215
+ self._out(f"[steered: {text}]\n")
216
+
217
+ # -- turns ------------------------------------------------------------
218
+
219
+ def _start_turn(self, message: str) -> None:
220
+ self._turn_task = asyncio.ensure_future(self._run_turn(message))
221
+ self._turn_task.add_done_callback(self._after_turn)
222
+
223
+ async def _run_turn(self, message: str) -> None:
224
+ try:
225
+ async for event in self.agent.prompt_stream(message):
226
+ self.renderer.handle(event)
227
+ except asyncio.CancelledError:
228
+ raise
229
+ except PiTimeoutError as exc:
230
+ self._out(f"\n[timeout] {exc}\n")
231
+ except Exception as exc: # keep the REPL alive on agent errors
232
+ self._out(f"\n[error] {exc}\n")
233
+
234
+ def _after_turn(self, task: asyncio.Task[None]) -> None:
235
+ if not task.cancelled():
236
+ task.exception() # retrieve to avoid "never retrieved" warnings
237
+ self._idle_prompt()
238
+
239
+ # -- approvals --------------------------------------------------------
240
+
241
+ async def _approve(self, request: ExtensionUIRequest) -> Any:
242
+ assert self._loop is not None
243
+ future: asyncio.Future[Any] = self._loop.create_future()
244
+ self._pending_approval = (request, future)
245
+ self._print_approval_prompt(request)
246
+ try:
247
+ return await future
248
+ finally:
249
+ self._pending_approval = None
250
+
251
+ def _print_approval_prompt(self, request: ExtensionUIRequest) -> None:
252
+ self.renderer._break() # ensure we start on a fresh line
253
+ if request.method == "confirm":
254
+ self._out(f"\n[approve] {request.title or 'Confirm'}? [y/N] ")
255
+ elif request.method == "select":
256
+ self._out(f"\n{request.title or 'Choose'}:\n")
257
+ for i, option in enumerate(request.options or []):
258
+ self._out(f" {i}) {option}\n")
259
+ self._out("[select] number: ")
260
+ elif request.method in ("input", "editor"):
261
+ self._out(f"\n[{request.method}] {request.title or 'Value'}: ")
262
+
263
+ # -- commands ---------------------------------------------------------
264
+
265
+ async def _model_label(self) -> str:
266
+ state = await self.agent.get_state()
267
+ model = state.get("model") or {}
268
+ return f"{model.get('provider')}/{model.get('id')}"
269
+
270
+ async def _command(self, line: str) -> bool:
271
+ """Handle a slash command. Return True to exit the REPL."""
272
+ parts = line.split()
273
+ cmd, args = parts[0].lower(), parts[1:]
274
+ if cmd in ("/exit", "/quit"):
275
+ return True
276
+ if cmd == "/help":
277
+ self._out(_HELP + "\n")
278
+ elif cmd == "/model":
279
+ self._out(await self._model_label() + "\n")
280
+ elif cmd == "/models":
281
+ for m in await self.agent.get_available_models():
282
+ self._out(f" {m.get('provider')}/{m.get('id')}\n")
283
+ elif cmd == "/new":
284
+ await self.agent.new_session()
285
+ self._out("[new session]\n")
286
+ elif cmd == "/state":
287
+ st = await self.agent.get_state()
288
+ self._out(
289
+ f"model={await self._model_label()} thinking={st.get('thinkingLevel')} "
290
+ f"messages={st.get('messageCount')} steering={st.get('steeringMode')} "
291
+ f"follow-up={st.get('followUpMode')}\n"
292
+ )
293
+ elif cmd == "/compact":
294
+ self._out("[compacting…]\n")
295
+ await self.agent.compact()
296
+ self._out("[compacted]\n")
297
+ elif cmd == "/clone":
298
+ result = await self.agent.clone()
299
+ self._out("[cloned]\n" if not result.get("cancelled") else "[clone cancelled]\n")
300
+ elif cmd == "/fork":
301
+ await self._fork(args)
302
+ else:
303
+ self._out(f"unknown command: {cmd} (try /help)\n")
304
+ return False
305
+
306
+ async def _fork(self, args: list[str]) -> None:
307
+ if args and args[0].isdigit():
308
+ index = int(args[0])
309
+ if not self._fork_list:
310
+ self._fork_list = await self.agent.get_fork_messages()
311
+ if 0 <= index < len(self._fork_list):
312
+ await self.agent.fork(self._fork_list[index]["entryId"])
313
+ self._out(f"[forked at message {index}]\n")
314
+ else:
315
+ self._out(f"no message {index}; run /fork to list\n")
316
+ return
317
+ self._fork_list = await self.agent.get_fork_messages()
318
+ if not self._fork_list:
319
+ self._out("[no forkable messages]\n")
320
+ return
321
+ self._out("Forkable messages (use /fork <n>):\n")
322
+ for i, msg in enumerate(self._fork_list):
323
+ text = (msg.get("text") or "").replace("\n", " ")
324
+ self._out(f" {i}) {text[:80]}\n")
325
+
326
+
327
+ async def run_repl(config: PiConfig, *, color: bool = True, initial: str | None = None) -> None:
328
+ async with PiAgent(config=config) as agent:
329
+ await Repl(agent, color=color).run(initial=initial)
330
+
331
+
332
+ async def run_once(config: PiConfig, message: str, *, color: bool = True) -> None:
333
+ renderer = Renderer(color=color)
334
+ async with PiAgent(config=config) as agent:
335
+ async for event in agent.prompt_stream(message):
336
+ renderer.handle(event)
337
+ print()
pi_py_agent/cli.py ADDED
@@ -0,0 +1,53 @@
1
+ """`pi-py` command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+
9
+ from pi_py_sdk import PiConfig
10
+
11
+ from .app import run_once, run_repl
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="pi-py",
17
+ description="A Python coding agent built on the Pi RPC bridge.",
18
+ )
19
+ parser.add_argument("prompt", nargs="*", help="optional prompt; omit for an interactive REPL")
20
+ parser.add_argument("--model", help="model id, e.g. anthropic/claude-sonnet-4-20250514")
21
+ parser.add_argument("--provider", help="provider override")
22
+ parser.add_argument("--cwd", help="working directory for the agent")
23
+ parser.add_argument("--session-dir", help="directory for session persistence")
24
+ parser.add_argument("--no-session", action="store_true", help="disable session persistence")
25
+ parser.add_argument("--print", action="store_true", help="one-shot: print the response and exit")
26
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
27
+ return parser
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ args = build_parser().parse_args(argv)
32
+ config = PiConfig(
33
+ model=args.model,
34
+ provider=args.provider,
35
+ cwd=args.cwd,
36
+ session_dir=args.session_dir,
37
+ no_session=args.no_session,
38
+ )
39
+ color = sys.stdout.isatty() and not args.no_color
40
+ prompt = " ".join(args.prompt).strip()
41
+
42
+ try:
43
+ if prompt and args.print:
44
+ asyncio.run(run_once(config, prompt, color=color))
45
+ else:
46
+ asyncio.run(run_repl(config, color=color, initial=prompt or None))
47
+ except KeyboardInterrupt:
48
+ return 130
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
pi_py_agent/py.typed ADDED
File without changes
pi_py_agent/render.py ADDED
@@ -0,0 +1,137 @@
1
+ """Render streamed agent events to a terminal.
2
+
3
+ Pure and testable: construct with a ``write`` callable and a ``color`` flag, then feed
4
+ it :class:`~pi_py_sdk.Event` objects. State is tracked only to insert sensible line
5
+ breaks between assistant text, thinking, and tool activity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable
11
+
12
+ from pi_py_sdk import Event
13
+
14
+ _RESET = "\033[0m"
15
+ _DIM = "\033[2m"
16
+ _CYAN = "\033[36m"
17
+ _YELLOW = "\033[33m"
18
+ _GREEN = "\033[32m"
19
+ _RED = "\033[31m"
20
+
21
+ # Keys whose value best summarizes a tool call, in priority order.
22
+ _ARG_KEYS = ("command", "path", "file_path", "pattern", "query", "url")
23
+ # Keys that typically carry a tool's textual result, in priority order.
24
+ _RESULT_KEYS = ("output", "stdout", "content", "text", "result", "message")
25
+
26
+ _PREVIEW_LINES = 8
27
+ _PREVIEW_WIDTH = 100
28
+
29
+
30
+ def _summarize_args(args: Any, limit: int = 80) -> str:
31
+ if isinstance(args, dict):
32
+ for key in _ARG_KEYS:
33
+ if key in args and args[key] is not None:
34
+ value = str(args[key]).replace("\n", " ")
35
+ return value if len(value) <= limit else value[: limit - 1] + "…"
36
+ return ""
37
+
38
+
39
+ def _result_text(result: Any) -> str:
40
+ """Best-effort extraction of human-readable text from a tool result."""
41
+ if result is None:
42
+ return ""
43
+ if isinstance(result, str):
44
+ return result
45
+ if isinstance(result, dict):
46
+ for key in _RESULT_KEYS:
47
+ value = result.get(key)
48
+ if isinstance(value, str) and value.strip():
49
+ return value
50
+ if isinstance(value, list): # content-block array
51
+ parts = [
52
+ item["text"]
53
+ for item in value
54
+ if isinstance(item, dict) and isinstance(item.get("text"), str)
55
+ ]
56
+ if parts:
57
+ return "\n".join(parts)
58
+ return ""
59
+
60
+
61
+ class Renderer:
62
+ def __init__(self, write: Callable[[str], Any] | None = None, *, color: bool = True):
63
+ if write is None:
64
+ import sys
65
+
66
+ write = sys.stdout.write
67
+ self._write = write
68
+ self.color = color
69
+ self._mode: str | None = None # "text" | "thinking" | None
70
+
71
+ def _c(self, code: str, text: str) -> str:
72
+ return f"{code}{text}{_RESET}" if self.color else text
73
+
74
+ def handle(self, event: Event) -> None:
75
+ kind = event.type
76
+ if kind == "message_update":
77
+ self._on_message_update(event)
78
+ elif kind == "tool_execution_start":
79
+ self._break()
80
+ name = getattr(event, "toolName", None) or "tool"
81
+ summary = _summarize_args(getattr(event, "args", None))
82
+ line = self._c(_CYAN, f"→ {name}") + (f" {summary}" if summary else "")
83
+ self._write(line + "\n")
84
+ elif kind == "tool_execution_end":
85
+ mark = self._c(_RED, "✗") if getattr(event, "isError", False) else self._c(_GREEN, "✓")
86
+ self._write(f" {mark}\n")
87
+ self._write_result_preview(getattr(event, "result", None))
88
+ elif kind == "queue_update":
89
+ steering = len(getattr(event, "steering", None) or [])
90
+ follow_up = len(getattr(event, "followUp", None) or [])
91
+ if steering or follow_up:
92
+ self._break()
93
+ self._write(self._c(_DIM, f"[queued: steering={steering} follow-up={follow_up}]") + "\n")
94
+ elif kind == "auto_retry_start":
95
+ self._break()
96
+ a, m = getattr(event, "attempt", "?"), getattr(event, "maxAttempts", "?")
97
+ self._write(self._c(_YELLOW, f"[retrying {a}/{m}…]") + "\n")
98
+ elif kind == "compaction_start":
99
+ self._break()
100
+ self._write(self._c(_YELLOW, "[compacting context…]") + "\n")
101
+ elif kind == "agent_end":
102
+ self._break()
103
+
104
+ def _on_message_update(self, event: Event) -> None:
105
+ ame = getattr(event, "assistantMessageEvent", None)
106
+ if ame is None or not getattr(ame, "delta", None):
107
+ return
108
+ if ame.type == "text_delta":
109
+ if self._mode != "text":
110
+ if self._mode == "thinking":
111
+ self._write("\n")
112
+ self._mode = "text"
113
+ self._write(ame.delta)
114
+ elif ame.type == "thinking_delta":
115
+ if self._mode != "thinking":
116
+ if self._mode == "text":
117
+ self._write("\n")
118
+ self._write(self._c(_DIM, "(thinking) "))
119
+ self._mode = "thinking"
120
+ self._write(self._c(_DIM, ame.delta))
121
+
122
+ def _write_result_preview(self, result: Any) -> None:
123
+ text = _result_text(result).strip("\n")
124
+ if not text:
125
+ return
126
+ lines = text.split("\n")
127
+ for line in lines[:_PREVIEW_LINES]:
128
+ clipped = line if len(line) <= _PREVIEW_WIDTH else line[: _PREVIEW_WIDTH - 1] + "…"
129
+ self._write(self._c(_DIM, f" │ {clipped}") + "\n")
130
+ if len(lines) > _PREVIEW_LINES:
131
+ extra = len(lines) - _PREVIEW_LINES
132
+ self._write(self._c(_DIM, f" │ … (+{extra} more lines)") + "\n")
133
+
134
+ def _break(self) -> None:
135
+ if self._mode is not None:
136
+ self._write("\n")
137
+ self._mode = None
pi_py_sdk/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """pi-py-sdk: Python SDK for the Pi coding agent over its RPC bridge.
2
+
3
+ Drives ``pi --mode rpc`` (the well-tested ``pi-agent-core`` runtime) as a subprocess,
4
+ exposing an async Python API. No agent logic is reimplemented in Python.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .client import PiAgent, UiHandler, UiResult
10
+ from .config import PiConfig
11
+ from .sync import PiAgentSync
12
+ from .errors import (
13
+ PiCommandError,
14
+ PiError,
15
+ PiNotStartedError,
16
+ PiProcessError,
17
+ PiTimeoutError,
18
+ )
19
+ from .protocol import (
20
+ DIALOG_METHODS,
21
+ AgentEndEvent,
22
+ AgentStartEvent,
23
+ AssistantMessage,
24
+ AssistantMessageEvent,
25
+ AutoRetryEndEvent,
26
+ AutoRetryStartEvent,
27
+ BashExecutionMessage,
28
+ CompactionEndEvent,
29
+ CompactionStartEvent,
30
+ Event,
31
+ ExtensionErrorEvent,
32
+ ExtensionUIRequest,
33
+ ImageContent,
34
+ MessageEndEvent,
35
+ MessageStartEvent,
36
+ MessageUpdateEvent,
37
+ QueueUpdateEvent,
38
+ Response,
39
+ SessionInfoChangedEvent,
40
+ TextContent,
41
+ ThinkingContent,
42
+ ThinkingLevelChangedEvent,
43
+ ToolCall,
44
+ ToolExecutionEndEvent,
45
+ ToolExecutionStartEvent,
46
+ ToolExecutionUpdateEvent,
47
+ ToolResultMessage,
48
+ TurnEndEvent,
49
+ UserMessage,
50
+ message_text,
51
+ parse_event,
52
+ parse_message,
53
+ parse_messages,
54
+ )
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ __all__ = [
59
+ "PiAgent",
60
+ "PiAgentSync",
61
+ "PiConfig",
62
+ "UiHandler",
63
+ "UiResult",
64
+ "PiError",
65
+ "PiNotStartedError",
66
+ "PiProcessError",
67
+ "PiTimeoutError",
68
+ "PiCommandError",
69
+ "Event",
70
+ "AgentStartEvent",
71
+ "AgentEndEvent",
72
+ "AssistantMessageEvent",
73
+ "MessageStartEvent",
74
+ "MessageUpdateEvent",
75
+ "MessageEndEvent",
76
+ "TurnEndEvent",
77
+ "ToolExecutionStartEvent",
78
+ "ToolExecutionUpdateEvent",
79
+ "ToolExecutionEndEvent",
80
+ "QueueUpdateEvent",
81
+ "CompactionStartEvent",
82
+ "CompactionEndEvent",
83
+ "AutoRetryStartEvent",
84
+ "AutoRetryEndEvent",
85
+ "SessionInfoChangedEvent",
86
+ "ThinkingLevelChangedEvent",
87
+ "ExtensionErrorEvent",
88
+ "ExtensionUIRequest",
89
+ "DIALOG_METHODS",
90
+ "Response",
91
+ "parse_event",
92
+ "TextContent",
93
+ "ThinkingContent",
94
+ "ImageContent",
95
+ "ToolCall",
96
+ "UserMessage",
97
+ "AssistantMessage",
98
+ "ToolResultMessage",
99
+ "BashExecutionMessage",
100
+ "parse_message",
101
+ "parse_messages",
102
+ "message_text",
103
+ "__version__",
104
+ ]
@@ -0,0 +1,41 @@
1
+ """Locate the Pi runtime.
2
+
3
+ Resolution order (confirmed project decision):
4
+ 1. An explicit ``bin`` (path or command name), if provided.
5
+ 2. ``pi`` discovered on ``PATH``.
6
+ 3. ``npx --yes @earendil-works/pi-coding-agent@<pinned>`` as a fallback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+
13
+ from .errors import PiProcessError
14
+
15
+ #: Pinned Pi package + version this SDK was developed and tested against.
16
+ PINNED_PI_PACKAGE = "@earendil-works/pi-coding-agent"
17
+ PINNED_PI_VERSION = "0.79.9"
18
+
19
+
20
+ def resolve_pi_command(bin: str | None = None) -> list[str]:
21
+ """Return the argv prefix that launches Pi (before ``--mode rpc`` args).
22
+
23
+ Raises:
24
+ PiProcessError: if neither an explicit/`PATH` binary nor ``npx`` is available.
25
+ """
26
+ if bin:
27
+ resolved = shutil.which(bin) or bin
28
+ return [resolved]
29
+
30
+ on_path = shutil.which("pi")
31
+ if on_path:
32
+ return [on_path]
33
+
34
+ npx = shutil.which("npx")
35
+ if npx:
36
+ return [npx, "--yes", f"{PINNED_PI_PACKAGE}@{PINNED_PI_VERSION}"]
37
+
38
+ raise PiProcessError(
39
+ "Could not find the `pi` binary on PATH and `npx` is unavailable. "
40
+ f"Install it with `npm i -g {PINNED_PI_PACKAGE}` (or ensure Node/npx is installed)."
41
+ )