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