pi-sdk-web 0.3.12 → 0.4.0

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,208 @@
1
+ #!/usr/bin/env python3
2
+ """pii - pi session helper.
3
+
4
+ pii list Show all sessions (name, id, cwd), newest first
5
+ pii list -l Also show session file paths
6
+ pii r <name> Resume the session with the given name via `pi --session <id>`
7
+ pii r <name> --web [port] Start web mode (default port 4080)
8
+ pii delete <name> Delete all sessions with the given name (asks for confirmation)
9
+ """
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(os.environ.get("PI_SESSION_DIR", Path.home() / ".pi" / "agent" / "sessions"))
17
+ # Project root: prefer PI_WEB_DIR env, then script location if inside project,
18
+ # then fall back to ~/pi-web (when installed globally as /usr/local/bin/pii).
19
+ def _resolve_project_root() -> Path:
20
+ env_dir = os.environ.get("PI_WEB_DIR")
21
+ if env_dir:
22
+ return Path(env_dir).expanduser().resolve()
23
+ script_dir = Path(__file__).resolve().parent
24
+ # If this script lives in <project>/pii/, the parent has server/
25
+ if (script_dir.parent / "server").is_dir():
26
+ return script_dir.parent
27
+ # Common install location fallback
28
+ home_pi_web = Path.home() / "pi-web"
29
+ if (home_pi_web / "server").is_dir():
30
+ return home_pi_web
31
+ return script_dir.parent.parent
32
+
33
+ PROJECT_ROOT = _resolve_project_root()
34
+
35
+
36
+ def sessions():
37
+ rows = []
38
+ for d in ROOT.iterdir() if ROOT.is_dir() else []:
39
+ if not d.is_dir():
40
+ continue
41
+ for f in d.glob("*.jsonl"):
42
+ try:
43
+ lines = f.read_text(errors="replace").splitlines()
44
+ except OSError:
45
+ continue
46
+ header = None
47
+ name = ""
48
+ for line in lines:
49
+ if not line.strip():
50
+ continue
51
+ try:
52
+ e = json.loads(line)
53
+ except json.JSONDecodeError:
54
+ continue
55
+ if header is None and e.get("type") == "session":
56
+ header = e
57
+ elif e.get("type") == "session_info":
58
+ n = e.get("name")
59
+ name = n.strip() if isinstance(n, str) else ""
60
+ if header is None:
61
+ continue
62
+ try:
63
+ mtime = f.stat().st_mtime
64
+ except OSError:
65
+ mtime = 0
66
+ rows.append({"name": name, "id": header.get("id", ""), "cwd": header.get("cwd", ""), "file": str(f), "mtime": mtime})
67
+ rows.sort(key=lambda r: r["mtime"], reverse=True)
68
+ return rows
69
+
70
+
71
+ def cmd_list(show_files=False):
72
+ rows = sessions()
73
+ if not rows:
74
+ print("(no sessions)")
75
+ return
76
+ width = max(len(r["name"] or "(unnamed)") for r in rows)
77
+ for r in rows:
78
+ line = f"{(r['name'] or '(unnamed)').ljust(width)} {r['id']} {r['cwd']}"
79
+ if show_files:
80
+ line += f" {r['file']}"
81
+ print(line)
82
+
83
+
84
+ def cmd_resume(name, web=False, port=None):
85
+ hits = [r for r in sessions() if r["name"] == name]
86
+ if not hits:
87
+ print(f"No session named '{name}'", file=sys.stderr)
88
+ names = sorted({r["name"] for r in sessions()})
89
+ if names:
90
+ print(f"Available names: {', '.join(names)}", file=sys.stderr)
91
+ sys.exit(1)
92
+ if len(hits) > 1:
93
+ print(f"Multiple sessions named '{name}', using most recent: {hits[0]['id']}", file=sys.stderr)
94
+ target = hits[0]
95
+ # cd into the session's project first, otherwise `pi --session` treats it
96
+ # as a foreign session and prompts to fork into the current directory.
97
+ cwd = target["cwd"]
98
+ if cwd and os.path.isdir(cwd):
99
+ os.chdir(cwd)
100
+ else:
101
+ print(f"Session cwd not found ({cwd}), keeping current directory", file=sys.stderr)
102
+
103
+ if web:
104
+ server_script = PROJECT_ROOT / "server" / "server.py"
105
+ if not server_script.is_file():
106
+ print(f"Web server not found: {server_script}", file=sys.stderr)
107
+ sys.exit(1)
108
+ cmd = [sys.executable, str(server_script), target["id"], target["cwd"], "--port", str(port or 4080)]
109
+ try:
110
+ sys.exit(subprocess.call(cmd))
111
+ except KeyboardInterrupt:
112
+ print()
113
+ sys.exit(130)
114
+ else:
115
+ try:
116
+ sys.exit(subprocess.call(["pi", "--session", target["id"]]))
117
+ except KeyboardInterrupt:
118
+ print()
119
+ sys.exit(130)
120
+
121
+
122
+ def confirm(prompt):
123
+ try:
124
+ answer = input(prompt)
125
+ except (EOFError, KeyboardInterrupt):
126
+ print()
127
+ return False
128
+ return answer.strip().lower() in ("y", "yes")
129
+
130
+
131
+ def cmd_delete(name):
132
+ hits = [r for r in sessions() if r["name"] == name]
133
+ if not hits:
134
+ print(f"No session named '{name}'", file=sys.stderr)
135
+ names = sorted({r["name"] for r in sessions()})
136
+ if names:
137
+ print(f"Available names: {', '.join(names)}", file=sys.stderr)
138
+ sys.exit(1)
139
+ if len(hits) == 1:
140
+ print(f"Deleting session '{name}':")
141
+ else:
142
+ print(f"{len(hits)} sessions named '{name}':")
143
+ for r in hits:
144
+ print(f" {r['id']} {r['cwd']} {r['file']}")
145
+ if not confirm(f"Delete {r['id']}? [y/N] "):
146
+ print(f" Skipped {r['id']}")
147
+ continue
148
+ try:
149
+ os.remove(r["file"])
150
+ print(f" Deleted {r['file']}")
151
+ except OSError as e:
152
+ print(f" Failed to delete {r['file']}: {e}", file=sys.stderr)
153
+ sys.exit(1)
154
+
155
+
156
+ def main():
157
+ if len(sys.argv) < 2:
158
+ print(__doc__)
159
+ sys.exit(1)
160
+ cmd = sys.argv[1]
161
+ if cmd in ("help", "-h", "--help"):
162
+ print(__doc__)
163
+ return
164
+ if cmd == "list":
165
+ cmd_list("-l" in sys.argv[2:])
166
+ elif cmd in ("r", "resume"):
167
+ args = sys.argv[2:]
168
+ name = None
169
+ web = False
170
+ port = None
171
+ i = 0
172
+ while i < len(args):
173
+ arg = args[i]
174
+ if arg == "--web":
175
+ web = True
176
+ # If next arg is a number, treat it as the port
177
+ if i + 1 < len(args) and args[i + 1].isdigit():
178
+ port = int(args[i + 1])
179
+ i += 1
180
+ elif name is None:
181
+ name = arg
182
+ else:
183
+ print(f"unexpected argument: {arg}", file=sys.stderr)
184
+ print("usage: pii r <name> [--web [port]]", file=sys.stderr)
185
+ sys.exit(1)
186
+ i += 1
187
+
188
+ if not name:
189
+ print("usage: pii r <name> [--web [port]]", file=sys.stderr)
190
+ sys.exit(1)
191
+
192
+ if port is not None and not (1 <= port <= 65535):
193
+ print(f"Invalid port: {port} (must be 1-65535)", file=sys.stderr)
194
+ sys.exit(1)
195
+
196
+ cmd_resume(name, web, port)
197
+ elif cmd == "delete":
198
+ if len(sys.argv) < 3:
199
+ print("usage: pii delete <name>", file=sys.stderr)
200
+ sys.exit(1)
201
+ cmd_delete(sys.argv[2])
202
+ else:
203
+ print(f"unknown command: {cmd}", file=sys.stderr)
204
+ sys.exit(1)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pii - pi session helper (RPC bridge launcher).
4
+ *
5
+ * This is the npm bin wrapper: pi-sdk-web ships the Python launcher
6
+ * (pi-bin/pii) plus the Python bridge (pi-bin/server/*.py, stdlib only).
7
+ * We spawn python3 with the bundled script and forward args/stdin/stdout/
8
+ * stderr, exiting with its code.
9
+ *
10
+ * Usage (same as the original script):
11
+ * pii list [-l]
12
+ * pii r <name> [--web [port]]
13
+ * pii delete <name>
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ const piiDir = dirname(fileURLToPath(import.meta.url)); // dist/pi-bin
20
+ const piiScript = join(piiDir, "pii");
21
+ if (!existsSync(piiScript)) {
22
+ console.error(`pii: bundled launcher not found at ${piiScript}`);
23
+ process.exit(1);
24
+ }
25
+ // Locate the Python interpreter (same resolution the original shell
26
+ // used: python3 on PATH; fall back to the `python` alias).
27
+ const pyCandidates = ["python3", "python"];
28
+ let python = undefined;
29
+ for (const candidate of pyCandidates) {
30
+ const probe = spawnSync(candidate, ["--version"], { stdio: "ignore" });
31
+ if (probe.status === 0) {
32
+ python = candidate;
33
+ break;
34
+ }
35
+ }
36
+ if (!python) {
37
+ console.error("pii: python3 not found on PATH (required to run the RPC bridge)");
38
+ process.exit(1);
39
+ }
40
+ // The bundled launcher resolves its project root via script location:
41
+ // dist/pi-bin/pi-bin/pii -> parent resolves to dist/pi-bin, whose parent
42
+ // is dist/ (no server/ there). The bridge lives at dist/pi-bin/server/,
43
+ // so export PI_WEB_DIR pointing at pi-bin so _resolve_project_root() picks
44
+ // the right server/ directory (its env_dir branch).
45
+ const result = spawnSync(python, [piiScript, ...process.argv.slice(2)], {
46
+ env: { ...process.env, PI_WEB_DIR: piiDir },
47
+ stdio: "inherit",
48
+ });
49
+ process.exit(result.status ?? 1);
@@ -0,0 +1,361 @@
1
+ """
2
+ Pi RPC client for the pi-web server.
3
+
4
+ Wraps a `pi --session <id> --mode rpc` subprocess, speaking the JSON-line protocol
5
+ over stdin/stdout.
6
+
7
+ Protocol (per pi-web-design.md and Pi source):
8
+ - Commands sent to stdin as JSON lines: {"id": "...", "type": "prompt", ...}
9
+ - Responses from stdout: {"id": "...", "type": "response", "command": ..., "success": true, "data": ...}
10
+ - Events from stdout: {"type": "message_start", ...} etc.
11
+ - Extension UI requests from stdout: {"type": "extension_ui_request", ...}
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import queue
20
+ import subprocess
21
+ import threading
22
+ import time
23
+ import uuid
24
+ from typing import Any, Callable, Optional
25
+
26
+ logger = logging.getLogger("rpc_client")
27
+
28
+
29
+ class RpcClient:
30
+ """Manages a Pi RPC subprocess and provides a command/event interface."""
31
+
32
+ def __init__(
33
+ self,
34
+ session_id: str,
35
+ cwd: str,
36
+ *,
37
+ port: Optional[int] = None,
38
+ env: Optional[dict] = None,
39
+ on_event: Optional[Callable[[dict], None]] = None,
40
+ on_exit: Optional[Callable[[int | None], None]] = None,
41
+ pi_cmd: str = "pi",
42
+ ) -> None:
43
+ self.session_id = session_id
44
+ self.cwd = cwd
45
+ self.on_event = on_event
46
+ self.on_exit = on_exit
47
+ self.pi_cmd = pi_cmd
48
+ self._env = env
49
+
50
+ self.proc: subprocess.Popen | None = None
51
+ self._pending: dict[str, dict] = {} # id -> holder dict (response stored here)
52
+ self._pending_events: dict[str, threading.Event] = {} # id -> event
53
+ self._lock = threading.Lock()
54
+ self._reader_thread: threading.Thread | None = None
55
+ self._writer_lock = threading.Lock()
56
+ self._stopped = threading.Event()
57
+ self._stderr_lines: list[str] = []
58
+ self._ready_event = threading.Event() # set once first event is seen
59
+ self._ready_lock = threading.Lock()
60
+ self._ready_seen = False
61
+
62
+ # ------------------------------------------------------------------
63
+ # Lifecycle
64
+ # ------------------------------------------------------------------
65
+
66
+ def start(self) -> None:
67
+ """Start the pi RPC subprocess and begin reading stdout."""
68
+ cmd = [self.pi_cmd, "--session", self.session_id, "--mode", "rpc"]
69
+ env = dict(os.environ)
70
+ if self._env:
71
+ env.update(self._env)
72
+
73
+ self.proc = subprocess.Popen(
74
+ cmd,
75
+ cwd=self.cwd,
76
+ stdin=subprocess.PIPE,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ text=True,
80
+ bufsize=0, # unbuffered; line-buffered (1) breaks stdin writes to pi RPC
81
+ encoding="utf-8",
82
+ errors="replace",
83
+ )
84
+ self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
85
+ self._reader_thread.start()
86
+ self._start_stderr_reader()
87
+
88
+ def _start_stderr_reader(self) -> None:
89
+ """Drain stderr so the pi process never blocks. Surface to a logger."""
90
+ def run() -> None:
91
+ assert self.proc and self.proc.stderr
92
+ for line in self.proc.stderr:
93
+ line = line.rstrip()
94
+ self._stderr_lines.append(line)
95
+ if logger.isEnabledFor(logging.DEBUG):
96
+ logger.debug("pi stderr: %s", line)
97
+ threading.Thread(target=run, daemon=True).start()
98
+
99
+ def wait_ready(
100
+ self,
101
+ timeout: float = 40.0,
102
+ interval: float = 2.0,
103
+ post_ready_delay: float = 0.5,
104
+ ) -> None:
105
+ """
106
+ Wait until the pi RPC process is ready to accept commands.
107
+
108
+ Empirically, Pi needs several seconds after emitting its first startup
109
+ events before it will reliably process stdin commands. So we:
110
+ 1. wait for the first event on stdout (RPC loop is running), then
111
+ 2. wait an additional ``post_ready_delay`` for startup to settle, then
112
+ 3. poll get_state until it responds successfully.
113
+
114
+ Raises RuntimeError if the process exits or times out.
115
+ """
116
+ deadline = time.time() + timeout
117
+ eyes_open = self._ready_event.wait(timeout=timeout)
118
+ if not eyes_open:
119
+ raise RuntimeError(
120
+ f"Timed out waiting for Pi RPC to become ready. "
121
+ f"Stderr: {'; '.join(self._stderr_lines[-5:])}"
122
+ )
123
+
124
+ # Give Pi time to finish initializing after it starts emitting events.
125
+ time.sleep(post_ready_delay)
126
+
127
+ # Now poll get_state until it responds.
128
+ last_err = ""
129
+ while time.time() < deadline:
130
+ if self.proc is not None and self.proc.poll() is not None:
131
+ raise RuntimeError(
132
+ f"Pi RPC process exited prematurely (code={self.proc.returncode}). "
133
+ f"Stderr: {'; '.join(self._stderr_lines[-5:])}"
134
+ )
135
+ try:
136
+ self.send("get_state", timeout=max(1.0, interval))
137
+ logger.info("Pi RPC ready (session %s)", self.session_id)
138
+ return
139
+ except RuntimeError as e:
140
+ last_err = str(e)
141
+ time.sleep(interval)
142
+ raise RuntimeError(f"Timed out waiting for Pi RPC to be ready. {last_err}")
143
+
144
+ def stop(self) -> None:
145
+ """Terminate the pi subprocess."""
146
+ if self._stopped.is_set():
147
+ return
148
+ self._stopped.set()
149
+ if self.proc is None:
150
+ return
151
+
152
+ # Try graceful terminate, then escalate to kill
153
+ if self.proc.poll() is None:
154
+ self.proc.terminate()
155
+ try:
156
+ self.proc.wait(timeout=3)
157
+ except subprocess.TimeoutExpired:
158
+ self.proc.kill()
159
+ self.proc.wait(timeout=3)
160
+
161
+ # ------------------------------------------------------------------
162
+ # Sending commands
163
+ # ------------------------------------------------------------------
164
+
165
+ def send(self, cmd_type: str, timeout: float = 120.0, **params: Any) -> dict:
166
+ """
167
+ Send a command and wait for its response synchronously.
168
+
169
+ Throws an exception if the pi process exited or the command errored.
170
+ Returns the response dict on success.
171
+
172
+ ``timeout`` bounds how long we wait for the command response.
173
+ """
174
+ cmd_id = uuid.uuid4().hex
175
+ payload: dict[str, Any] = {"id": cmd_id, "type": cmd_type, **params}
176
+
177
+ done = threading.Event()
178
+ holder: dict = {} # will hold the response; same reference stored in _pending
179
+ with self._lock:
180
+ # Store the holder dict directly (not a nested dict) so that
181
+ # _handle_response's writes to holder["response"] are visible here.
182
+ self._pending[cmd_id] = holder
183
+ # keep event separately for correlation in _handle_response
184
+ self._pending_events[cmd_id] = done
185
+
186
+ self._write(payload)
187
+
188
+ # Wait for response (bounded; agent may take a while to idle, but the
189
+ # response to most commands is immediate once the process is ready).
190
+ done.wait(timeout=timeout)
191
+ with self._lock:
192
+ self._pending.pop(cmd_id, None)
193
+ self._pending_events.pop(cmd_id, None)
194
+
195
+ response = holder.get("response")
196
+ if response is None:
197
+ raise RuntimeError(f"No response for command {cmd_type} (process may have exited)")
198
+
199
+ if not response.get("success", False):
200
+ raise RuntimeError(response.get("error", f"Command {cmd_type} failed"))
201
+ return response
202
+
203
+ def send_async(self, cmd_type: str, **params: Any) -> str:
204
+ """
205
+ Send a command without waiting for its response. Used for commands whose
206
+ response is handled by the event loop (e.g. prompt, which emits events
207
+ and an async response).
208
+
209
+ Returns the command id so callers can correlate if needed.
210
+ """
211
+ cmd_id = uuid.uuid4().hex
212
+ payload: dict[str, Any] = {"id": cmd_id, "type": cmd_type, **params}
213
+ self._write(payload)
214
+ return cmd_id
215
+
216
+ def _write(self, payload: dict) -> None:
217
+ if self.proc is None or self.proc.stdin is None or self.proc.poll() is not None:
218
+ raise RuntimeError("Pi RPC process is not running")
219
+ line = json.dumps(payload, ensure_ascii=False) + "\n"
220
+ with self._writer_lock:
221
+ self.proc.stdin.write(line)
222
+ self.proc.stdin.flush()
223
+
224
+ # ------------------------------------------------------------------
225
+ # Reading stdout
226
+ # ------------------------------------------------------------------
227
+
228
+ def _read_loop(self) -> None:
229
+ assert self.proc and self.proc.stdout
230
+ for line in self.proc.stdout:
231
+ if self._stopped.is_set():
232
+ break
233
+ line = line.strip()
234
+ if not line:
235
+ continue
236
+ try:
237
+ data = json.loads(line)
238
+ except json.JSONDecodeError:
239
+ continue
240
+
241
+ # Mark readiness once the first event (e.g. extension_ui_request/setStatus)
242
+ # or response arrives on stdout. This signals that the Pi RPC loop has
243
+ # taken over and is ready to process commands.
244
+ with self._ready_lock:
245
+ if not self._ready_seen:
246
+ self._ready_seen = True
247
+ self._ready_event.set()
248
+ logger.info("First stdout line received from pi (ready signal)")
249
+
250
+ if data.get("type") == "response":
251
+ self._handle_response(data)
252
+ else:
253
+ # Event or extension_ui_request -> broadcast
254
+ if self.on_event:
255
+ try:
256
+ self.on_event(data)
257
+ except Exception:
258
+ pass
259
+
260
+ # Process exited
261
+ if self.on_exit:
262
+ try:
263
+ self.on_exit(self.proc.returncode if self.proc else None)
264
+ except Exception:
265
+ pass
266
+
267
+ def _handle_response(self, data: dict) -> None:
268
+ cmd_id = data.get("id")
269
+ if not cmd_id:
270
+ return
271
+ with self._lock:
272
+ holder = self._pending.get(cmd_id)
273
+ done = self._pending_events.get(cmd_id)
274
+ logger.debug("handle_response id=%s in_pending=%s", cmd_id, holder is not None)
275
+ if holder is None:
276
+ # Async response for a command we don't track (e.g. prompt) - still broadcast
277
+ if self.on_event:
278
+ try:
279
+ self.on_event(data)
280
+ except Exception:
281
+ pass
282
+ return
283
+ # holder is the same dict reference returned by send(); store the response
284
+ # so send() can read it after the event fires.
285
+ holder["response"] = data
286
+ if done is not None:
287
+ done.set()
288
+
289
+
290
+ # Convenience command wrappers
291
+ class RpcCommands:
292
+ """Thin wrappers around common RPC commands."""
293
+
294
+ def __init__(self, client: RpcClient):
295
+ self._c = client
296
+
297
+ def get_state(self) -> dict:
298
+ return self._c.send("get_state")
299
+
300
+ def get_session_stats(self) -> dict:
301
+ return self._c.send("get_session_stats")
302
+
303
+ def get_messages(self) -> list:
304
+ resp = self._c.send("get_messages")
305
+ return resp.get("data", {}).get("messages", [])
306
+
307
+ def get_entries(self, since: str | None = None) -> dict:
308
+ params = {}
309
+ if since is not None:
310
+ params["since"] = since
311
+ resp = self._c.send("get_entries", **params)
312
+ return resp.get("data", {})
313
+
314
+ def prompt(self, message: str) -> str:
315
+ return self._c.send_async("prompt", message=message)
316
+
317
+ def abort(self) -> None:
318
+ self._c.send("abort")
319
+
320
+ def get_available_models(self) -> list:
321
+ resp = self._c.send("get_available_models")
322
+ return resp.get("data", {}).get("models", [])
323
+
324
+ def set_model(self, provider: str, model_id: str) -> dict:
325
+ return self._c.send("set_model", provider=provider, modelId=model_id)
326
+
327
+ def cycle_model(self) -> dict:
328
+ return self._c.send("cycle_model")
329
+
330
+ def get_available_thinking_levels(self) -> list:
331
+ resp = self._c.send("get_available_thinking_levels")
332
+ return resp.get("data", {}).get("levels", [])
333
+
334
+ def set_thinking_level(self, level: str) -> None:
335
+ self._c.send("set_thinking_level", level=level)
336
+
337
+ def cycle_thinking_level(self) -> dict:
338
+ return self._c.send("cycle_thinking_level")
339
+
340
+ def get_commands(self) -> list:
341
+ resp = self._c.send("get_commands")
342
+ return resp.get("data", {}).get("commands", [])
343
+
344
+ def bash(self, command: str) -> dict:
345
+ resp = self._c.send("bash", command=command)
346
+ return resp.get("data", {})
347
+
348
+ def compact(self, custom_instructions: str | None = None) -> dict:
349
+ params = {}
350
+ if custom_instructions:
351
+ params["customInstructions"] = custom_instructions
352
+ resp = self._c.send("compact", **params)
353
+ return resp.get("data", {})
354
+
355
+ def set_session_name(self, name: str) -> None:
356
+ self._c.send("set_session_name", name=name)
357
+
358
+ def extension_ui_response(self, response_id: str, **data) -> None:
359
+ """Send a response to a pending extension UI request (select/confirm/input/editor)."""
360
+ payload = {"type": "extension_ui_response", "id": response_id, **data}
361
+ self._c._write(payload)