pi-sdk-web 0.3.13 → 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)
@@ -0,0 +1,451 @@
1
+ """
2
+ pi-web server
3
+
4
+ Bridges a browser (via WebSocket) to a Pi RPC subprocess.
5
+
6
+ Run standalone:
7
+ python3 server.py <session_id> <cwd> [--port <port>]
8
+
9
+ The pii script will eventually invoke this server after resolving the session
10
+ name to a session id + cwd.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import logging
18
+ import os
19
+ import socket
20
+ import threading
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ logging.basicConfig(level=logging.DEBUG)
26
+
27
+ from rpc_client import RpcClient, RpcCommands
28
+ from websocket import WebSocketConnection, WebSocketServer
29
+
30
+ DEFAULT_PORT = 4080
31
+ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
32
+
33
+
34
+ class Peripheral:
35
+ """Holds the Pi RPC client and manages WebSocket clients."""
36
+
37
+ def __init__(self, session_id: str, cwd: str):
38
+ self.session_id = session_id
39
+ self.cwd = cwd
40
+ self.client = RpcClient(session_id, cwd, on_event=self._on_rpc_event, on_exit=self._on_rpc_exit)
41
+ self.commands = RpcCommands(self.client)
42
+ self._clients: set[WebSocketConnection] = set()
43
+ self._clients_lock = threading.Lock()
44
+ self._exit_error: Optional[str] = None
45
+ self._version = self._get_pi_version()
46
+
47
+ # ------------------------------------------------------------------
48
+ # Lifecycle
49
+ # ------------------------------------------------------------------
50
+
51
+ def start(self) -> None:
52
+ self.client.start()
53
+ self.client.wait_ready()
54
+
55
+ def stop(self) -> None:
56
+ self.client.stop()
57
+
58
+ # ------------------------------------------------------------------
59
+ # WebSocket client management
60
+ # ------------------------------------------------------------------
61
+
62
+ def add_client(self, conn: WebSocketConnection) -> None:
63
+ with self._clients_lock:
64
+ self._clients.add(conn)
65
+ # Send initial state to the new client
66
+ self._send_initial_state(conn)
67
+
68
+ def remove_client(self, conn: WebSocketConnection) -> None:
69
+ with self._clients_lock:
70
+ self._clients.discard(conn)
71
+
72
+ def _broadcast(self, obj: object) -> None:
73
+ message = json.dumps(obj, ensure_ascii=False)
74
+ dead: list[WebSocketConnection] = []
75
+ with self._clients_lock:
76
+ for conn in list(self._clients):
77
+ try:
78
+ conn.send_text(message)
79
+ except Exception:
80
+ dead.append(conn)
81
+ for conn in dead:
82
+ self.remove_client(conn)
83
+
84
+ # ------------------------------------------------------------------
85
+ # RPC event -> broadcast
86
+ # ------------------------------------------------------------------
87
+
88
+ # Events after which footer stats should be refreshed (TUI does this too)
89
+ _STATS_REFRESH_EVENTS = {
90
+ "agent_settled",
91
+ "turn_end",
92
+ "tool_execution_end",
93
+ "compaction_end",
94
+ "entry_appended",
95
+ "session_info_changed",
96
+ "thinking_level_changed",
97
+ }
98
+
99
+ def _on_rpc_event(self, data: dict) -> None:
100
+ self._broadcast(data)
101
+ if data.get("type") in self._STATS_REFRESH_EVENTS:
102
+ self._schedule_stats_refresh()
103
+
104
+ def _schedule_stats_refresh(self) -> None:
105
+ """Fetch and broadcast latest session stats in a background thread.
106
+
107
+ This must not run in the RPC read-loop thread, because get_session_stats
108
+ is a synchronous command that would deadlock if issued from there.
109
+ """
110
+ def run() -> None:
111
+ try:
112
+ stats = self.commands.get_session_stats().get("data", {})
113
+ self._broadcast({"type": "stats", "data": stats})
114
+ except Exception:
115
+ pass
116
+ threading.Thread(target=run, daemon=True).start()
117
+
118
+ def _on_rpc_exit(self, code: int | None) -> None:
119
+ logging.warning("Pi RPC process exited with code=%s stderr=%s", code, self.client._stderr_lines[-5:])
120
+ self._exit_error = f"Pi RPC process exited (code={code})"
121
+ self._broadcast({"type": "pi_error", "error": self._exit_error})
122
+
123
+ # ------------------------------------------------------------------
124
+ # Initial state for new clients
125
+ # ------------------------------------------------------------------
126
+
127
+ def _send_initial_state(self, conn: WebSocketConnection) -> None:
128
+ """Send history + state to a newly connected browser client."""
129
+ try:
130
+ conn.send_json({"type": "state", "data": self._build_state()})
131
+ self._send_history(conn)
132
+ except RuntimeError as e:
133
+ conn.send_json({"type": "error", "error": str(e)})
134
+
135
+ def _build_state(self) -> dict:
136
+ """Build the full session state dict (model/thinking/stats/version/...)."""
137
+ state = self.commands.get_state()
138
+ data = state.get("data", {})
139
+ data["cwd"] = self._format_cwd_for_footer(self.cwd)
140
+ data["gitBranch"] = self._get_git_branch()
141
+ data["sessionStats"] = self.commands.get_session_stats().get("data", {})
142
+ data["version"] = self._version
143
+ data["commands"] = self.commands.get_commands()
144
+ return data
145
+
146
+ def _broadcast_state(self) -> None:
147
+ """Broadcast the full state after mutations (model/thinking/session name)."""
148
+ try:
149
+ self._broadcast({"type": "state", "data": self._build_state()})
150
+ except Exception:
151
+ pass
152
+
153
+ def _get_pi_version(self) -> str:
154
+ """Get the installed pi version once."""
155
+ try:
156
+ import subprocess
157
+
158
+ result = subprocess.run(
159
+ ["pi", "--version"],
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=5,
163
+ )
164
+ if result.returncode == 0:
165
+ return result.stdout.strip()
166
+ except Exception:
167
+ pass
168
+ return ""
169
+
170
+ def _format_cwd_for_footer(self, cwd: str) -> str:
171
+ """Show home directory as ~ like the TUI footer."""
172
+ home = os.path.expanduser("~")
173
+ try:
174
+ rel = os.path.relpath(cwd, home)
175
+ if rel == ".":
176
+ return "~"
177
+ if not rel.startswith(".."):
178
+ return f"~/{rel}"
179
+ except Exception:
180
+ pass
181
+ return cwd
182
+
183
+ def _get_git_branch(self) -> str | None:
184
+ """Return the current git branch of the session cwd, if any."""
185
+ try:
186
+ import subprocess
187
+
188
+ result = subprocess.run(
189
+ ["git", "branch", "--show-current"],
190
+ cwd=self.cwd,
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=3,
194
+ )
195
+ if result.returncode == 0:
196
+ branch = result.stdout.strip()
197
+ return branch or None
198
+ except Exception:
199
+ pass
200
+ return None
201
+
202
+ def _send_history(self, conn: WebSocketConnection) -> None:
203
+ try:
204
+ entries = self.commands.get_entries()
205
+ conn.send_json({"type": "history", "data": entries})
206
+ except RuntimeError as e:
207
+ conn.send_json({"type": "error", "error": str(e)})
208
+
209
+ # ------------------------------------------------------------------
210
+ # Handle incoming WebSocket messages (browser -> Pi)
211
+ # ------------------------------------------------------------------
212
+
213
+ def handle_client_message(self, conn: WebSocketConnection, raw: str) -> None:
214
+ logging.debug("WS message: %s", raw[:200])
215
+ try:
216
+ data = json.loads(raw)
217
+ except json.JSONDecodeError:
218
+ conn.send_json({"type": "error", "error": "Invalid JSON"})
219
+ return
220
+
221
+ cmd_type = data.get("type")
222
+ if not cmd_type:
223
+ conn.send_json({"type": "error", "error": "Missing 'type'"})
224
+ return
225
+
226
+ try:
227
+ self._dispatch_command(cmd_type, data)
228
+ except RuntimeError as e:
229
+ conn.send_json({"type": "error", "error": str(e)})
230
+
231
+ def _dispatch_command(self, cmd_type: str, data: dict) -> None:
232
+ if cmd_type == "prompt":
233
+ message = data.get("message", "")
234
+ if not message:
235
+ raise RuntimeError("Missing 'message'")
236
+ self.commands.prompt(message)
237
+ elif cmd_type == "abort":
238
+ self.commands.abort()
239
+ elif cmd_type == "get_state":
240
+ pass # broadcast via client init; ignore
241
+ elif cmd_type == "get_stats":
242
+ stats = self.commands.get_session_stats().get("data", {})
243
+ self._broadcast({"type": "stats", "data": stats})
244
+ elif cmd_type == "bash":
245
+ command = data.get("command", "")
246
+ if not command:
247
+ raise RuntimeError("Missing 'command'")
248
+ result = self.commands.bash(command)
249
+ self._broadcast({"type": "bash_result", "command": command, "data": result})
250
+ elif cmd_type == "cycle_model":
251
+ self.commands.cycle_model()
252
+ self._broadcast_state()
253
+ elif cmd_type == "set_model":
254
+ provider = data.get("provider")
255
+ model_id = data.get("modelId")
256
+ if not provider or not model_id:
257
+ raise RuntimeError("Missing 'provider' or 'modelId'")
258
+ self.commands.set_model(provider, model_id)
259
+ self._broadcast_state()
260
+ elif cmd_type == "get_available_models":
261
+ models = self.commands.get_available_models()
262
+ self._broadcast({"type": "models", "data": models})
263
+ elif cmd_type == "cycle_thinking_level":
264
+ self.commands.cycle_thinking_level()
265
+ self._broadcast_state()
266
+ elif cmd_type == "set_thinking_level":
267
+ level = data.get("level")
268
+ if not level:
269
+ raise RuntimeError("Missing 'level'")
270
+ self.commands.set_thinking_level(level)
271
+ self._broadcast_state()
272
+ elif cmd_type == "get_available_thinking_levels":
273
+ levels = self.commands.get_available_thinking_levels()
274
+ self._broadcast({"type": "thinking_levels", "data": levels})
275
+ elif cmd_type == "compact":
276
+ custom_instructions = data.get("customInstructions")
277
+ self.commands.compact(custom_instructions)
278
+ elif cmd_type == "set_session_name":
279
+ name = data.get("name", "")
280
+ if not name:
281
+ raise RuntimeError("Missing 'name'")
282
+ self.commands.set_session_name(name)
283
+ self._broadcast_state()
284
+ elif cmd_type == "extension_ui_response":
285
+ response_id = data.get("id")
286
+ if not response_id:
287
+ raise RuntimeError("Missing 'id'")
288
+ # Pass through all remaining fields (value/confirmed/cancelled)
289
+ extra = {k: v for k, v in data.items() if k not in ("type", "id")}
290
+ self.commands.extension_ui_response(response_id, **extra)
291
+ else:
292
+ raise RuntimeError(f"Unsupported command: {cmd_type}")
293
+
294
+
295
+ class ServerContext:
296
+ """Shared state accessible from HTTP request handlers."""
297
+
298
+ def __init__(self, peripheral: Peripheral):
299
+ self.peripheral = peripheral
300
+ self.ws = WebSocketServer(self._on_ws_connection)
301
+
302
+ def _on_ws_connection(self, conn: WebSocketConnection, request_headers: dict) -> None:
303
+ pass
304
+
305
+
306
+ class HTTPHandler(BaseHTTPRequestHandler):
307
+ """Serves static files and upgrades WebSocket connections."""
308
+
309
+ context: ServerContext = None # type: ignore
310
+
311
+ def log_message(self, format: str, *args) -> None:
312
+ # Quiet by default
313
+ pass
314
+
315
+ def finish(self) -> None:
316
+ # For WebSocket upgrades we hand the raw socket to a WebSocketConnection
317
+ # and must NOT let BaseHTTPRequestHandler close it.
318
+ if getattr(self, "_ws_upgraded", False):
319
+ return
320
+ super().finish()
321
+
322
+ def _send_file(self, path: Path, content_type: str) -> None:
323
+ try:
324
+ data = path.read_bytes()
325
+ self.send_response(200)
326
+ self.send_header("Content-Type", content_type)
327
+ self.send_header("Content-Length", str(len(data)))
328
+ self.end_headers()
329
+ self.wfile.write(data)
330
+ except OSError:
331
+ self.send_error(404)
332
+
333
+ def do_GET(self) -> None: # noqa: N802
334
+ # WebSocket upgrade
335
+ if self.headers.get("Upgrade", "").lower() == "websocket":
336
+ self._handle_ws_upgrade()
337
+ return
338
+
339
+ path = self.path.split("?")[0]
340
+ if path == "/":
341
+ path = "/index.html"
342
+
343
+ # Resolve static path safely
344
+ rel = path.lstrip("/")
345
+ file_path = (STATIC_DIR / rel).resolve()
346
+ if not str(file_path).startswith(str(STATIC_DIR.resolve())):
347
+ self.send_error(403)
348
+ return
349
+
350
+ if not file_path.exists():
351
+ self.send_error(404)
352
+ return
353
+
354
+ ext = file_path.suffix.lower()
355
+ content_type = {
356
+ ".html": "text/html; charset=utf-8",
357
+ ".css": "text/css; charset=utf-8",
358
+ ".js": "application/javascript; charset=utf-8",
359
+ ".json": "application/json; charset=utf-8",
360
+ ".svg": "image/svg+xml",
361
+ ".png": "image/png",
362
+ ".jpg": "image/jpeg",
363
+ ".ico": "image/x-icon",
364
+ }.get(ext, "application/octet-stream")
365
+ self._send_file(file_path, content_type)
366
+
367
+ def _handle_ws_upgrade(self) -> None:
368
+ ctx = self.server.ws_context
369
+ key = self.headers.get("Sec-WebSocket-Key")
370
+
371
+ import base64
372
+ import hashlib
373
+
374
+ accept = base64.b64encode(
375
+ hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
376
+ ).decode()
377
+
378
+ self.send_response(101)
379
+ self.send_header("Upgrade", "websocket")
380
+ self.send_header("Connection", "Upgrade")
381
+ self.send_header("Sec-WebSocket-Accept", accept)
382
+ self.end_headers()
383
+
384
+ # Take over the socket. We detach the underlying fd so that when the
385
+ # HTTP handler/server finishes it cannot close the live WebSocket.
386
+ fd = self.connection.detach()
387
+ sock = socket.socket(fileno=fd)
388
+ self._ws_upgraded = True
389
+ self.close_connection = True
390
+
391
+ peripheral = ctx.peripheral
392
+
393
+ def on_message(msg: str) -> None:
394
+ peripheral.handle_client_message(conn, msg)
395
+
396
+ def on_close() -> None:
397
+ peripheral.remove_client(conn)
398
+
399
+ conn = WebSocketConnection(sock, on_message, on_close)
400
+ ctx.clients.append(conn)
401
+ peripheral.add_client(conn)
402
+ conn.start_reading()
403
+
404
+
405
+ class PiWebHTTPServer(ThreadingHTTPServer):
406
+ daemon_threads = True
407
+
408
+ def __init__(self, addr: tuple, handler_cls, peripheral: Peripheral):
409
+ super().__init__(addr, handler_cls)
410
+ self.ws_context = ServerContext(peripheral)
411
+ self.ws_context.clients = []
412
+ handler_cls.context = self.ws_context
413
+
414
+
415
+ def main() -> None:
416
+ parser = argparse.ArgumentParser(description="pi-web server")
417
+ parser.add_argument("session_id", help="Pi session id")
418
+ parser.add_argument("cwd", help="Session working directory")
419
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind (default: 4080)")
420
+ args = parser.parse_args()
421
+
422
+ if not (1 <= args.port <= 65535):
423
+ print(f"Invalid port: {args.port}", file=os.sys.stderr)
424
+ os.sys.exit(1)
425
+
426
+ if not os.path.isdir(args.cwd):
427
+ print(f"Working directory not found: {args.cwd}", file=os.sys.stderr)
428
+ os.sys.exit(1)
429
+
430
+ peripheral = Peripheral(args.session_id, args.cwd)
431
+
432
+ try:
433
+ httpd = PiWebHTTPServer(("127.0.0.1", args.port), HTTPHandler, peripheral)
434
+ except OSError as e:
435
+ print(f"Failed to bind 127.0.0.1:{args.port}: {e}", file=os.sys.stderr)
436
+ os.sys.exit(1)
437
+
438
+ peripheral.start()
439
+ print(f"server at http://127.0.0.1:{args.port}/", flush=True)
440
+
441
+ try:
442
+ httpd.serve_forever()
443
+ except KeyboardInterrupt:
444
+ pass
445
+ finally:
446
+ peripheral.stop()
447
+ httpd.server_close()
448
+
449
+
450
+ if __name__ == "__main__":
451
+ main()
@@ -0,0 +1,204 @@
1
+ """
2
+ Simple WebSocket server implementation using only the Python standard library.
3
+
4
+ Supports:
5
+ - HTTP handshake (RFC 6455)
6
+ - Text and binary frames
7
+ - Ping/Pong keepalive
8
+ - Fragmented message reassembly
9
+ - Client close handling
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import socket
17
+ import struct
18
+ import threading
19
+ from typing import Callable, Optional
20
+
21
+ MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
22
+
23
+ OP_CONT = 0x0
24
+ OP_TEXT = 0x1
25
+ OP_BINARY = 0x2
26
+ OP_CLOSE = 0x8
27
+ OP_PING = 0x9
28
+ OP_PONG = 0xA
29
+
30
+
31
+ class WebSocketError(Exception):
32
+ pass
33
+
34
+
35
+ class WebSocketConnection:
36
+ """A single WebSocket client connection."""
37
+
38
+ def __init__(
39
+ self,
40
+ sock: socket.socket,
41
+ on_message: Callable[[str], None],
42
+ on_close: Callable[[], None],
43
+ ) -> None:
44
+ self.sock = sock
45
+ self.on_message = on_message
46
+ self.on_close = on_close
47
+ self._lock = threading.Lock()
48
+ self._closed = False
49
+ self._reader_thread: Optional[threading.Thread] = None
50
+
51
+ # ------------------------------------------------------------------
52
+ # Reading
53
+ # ------------------------------------------------------------------
54
+
55
+ def start_reading(self) -> None:
56
+ self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
57
+ self._reader_thread.start()
58
+
59
+ def _read_exact(self, n: int) -> bytes:
60
+ buf = b""
61
+ while len(buf) < n:
62
+ chunk = self.sock.recv(n - len(buf))
63
+ if not chunk:
64
+ raise WebSocketError("connection closed")
65
+ buf += chunk
66
+ return buf
67
+
68
+ def _read_loop(self) -> None:
69
+ try:
70
+ while not self._closed:
71
+ self._read_frame()
72
+ except (WebSocketError, socket.error, OSError):
73
+ pass
74
+ finally:
75
+ self.close()
76
+ if self.on_close:
77
+ try:
78
+ self.on_close()
79
+ except Exception:
80
+ pass
81
+
82
+ def _read_frame(self) -> None:
83
+ header = self._read_exact(2)
84
+ fin = (header[0] >> 7) & 0x01
85
+ opcode = header[0] & 0x0F
86
+ masked = (header[1] >> 7) & 0x01
87
+ length = header[1] & 0x7F
88
+
89
+ if length == 126:
90
+ length = struct.unpack(">H", self._read_exact(2))[0]
91
+ elif length == 127:
92
+ length = struct.unpack(">Q", self._read_exact(8))[0]
93
+
94
+ mask_key = self._read_exact(4) if masked else None
95
+ payload = self._read_exact(length)
96
+ if mask_key:
97
+ payload = bytes(
98
+ b ^ mask_key[i % 4] for i, b in enumerate(payload)
99
+ )
100
+
101
+ if opcode == OP_TEXT:
102
+ self.on_message(payload.decode("utf-8", errors="replace"))
103
+ elif opcode == OP_BINARY:
104
+ self.on_message(payload.decode("utf-8", errors="replace"))
105
+ elif opcode == OP_PING:
106
+ self._send_frame(OP_PONG, payload)
107
+ elif opcode == OP_PONG:
108
+ pass
109
+ elif opcode == OP_CLOSE:
110
+ self.close()
111
+ elif opcode == OP_CONT:
112
+ # For simplicity, treat continuation frames as pass-through text.
113
+ self.on_message(payload.decode("utf-8", errors="replace"))
114
+ else:
115
+ raise WebSocketError(f"unsupported opcode {opcode}")
116
+
117
+ # ------------------------------------------------------------------
118
+ # Writing
119
+ # ------------------------------------------------------------------
120
+
121
+ def _send_frame(self, opcode: int, payload: bytes) -> None:
122
+ if self._closed:
123
+ return
124
+ with self._lock:
125
+ header = bytearray()
126
+ header.append(0x80 | opcode)
127
+ length = len(payload)
128
+ if length < 126:
129
+ header.append(length)
130
+ elif length < 65536:
131
+ header.append(126)
132
+ header.extend(struct.pack(">H", length))
133
+ else:
134
+ header.append(127)
135
+ header.extend(struct.pack(">Q", length))
136
+ try:
137
+ self.sock.sendall(bytes(header) + payload)
138
+ except (socket.error, OSError):
139
+ self.close()
140
+
141
+ def send_text(self, message: str) -> None:
142
+ self._send_frame(OP_TEXT, message.encode("utf-8"))
143
+
144
+ def send_json(self, obj: object) -> None:
145
+ import json
146
+
147
+ self.send_text(json.dumps(obj, ensure_ascii=False))
148
+
149
+ # ------------------------------------------------------------------
150
+ # Close
151
+ # ------------------------------------------------------------------
152
+
153
+ def close(self) -> None:
154
+ if self._closed:
155
+ return
156
+ self._closed = True
157
+ try:
158
+ self.sock.close()
159
+ except OSError:
160
+ pass
161
+
162
+
163
+ class WebSocketServer:
164
+ """Minimal WebSocket server that handles one handshake per incoming connection."""
165
+
166
+ def __init__(
167
+ self,
168
+ handle_connection: Callable[[WebSocketConnection, dict], None],
169
+ ) -> None:
170
+ self.handle_connection = handle_connection
171
+
172
+ def upgrade(self, sock: socket.socket, request: bytes) -> Optional[WebSocketConnection]:
173
+ """Perform the WebSocket handshake and return a connection, or None on failure."""
174
+ try:
175
+ key = self._extract_key(request)
176
+ if not key:
177
+ sock.close()
178
+ return None
179
+
180
+ accept = base64.b64encode(
181
+ hashlib.sha1((key + MAGIC).encode()).digest()
182
+ ).decode()
183
+
184
+ response = (
185
+ "HTTP/1.1 101 Switching Protocols\r\n"
186
+ "Upgrade: websocket\r\n"
187
+ "Connection: Upgrade\r\n"
188
+ f"Sec-WebSocket-Accept: {accept}\r\n"
189
+ "\r\n"
190
+ )
191
+ sock.sendall(response.encode())
192
+ except (socket.error, OSError):
193
+ return None
194
+
195
+ conn = WebSocketConnection(sock, lambda msg: None, lambda: None)
196
+ return conn
197
+
198
+ @staticmethod
199
+ def _extract_key(request: bytes) -> Optional[str]:
200
+ text = request.decode("utf-8", errors="replace")
201
+ for line in text.split("\r\n"):
202
+ if line.lower().startswith("sec-websocket-key:"):
203
+ return line.split(":", 1)[1].strip()
204
+ return None
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.3.13",
3
+ "version": "0.4.0",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pi-web": "./dist/cli.js"
7
+ "pi-web": "./dist/cli.js",
8
+ "pii": "./dist/pi-bin/pii-cli.js"
8
9
  },
9
10
  "files": [
10
11
  "dist"
11
12
  ],
12
13
  "scripts": {
13
- "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true })\"",
14
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
14
15
  "dev": "tsx src/cli.ts",
15
16
  "verify": "tsx src/verify-sdk.ts",
16
17
  "prepublishOnly": "npm run build"