pi-sdk-web 0.3.12 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pi-bin/pii +208 -0
- package/dist/pi-bin/pii-cli.js +49 -0
- package/dist/pi-bin/server/rpc_client.py +361 -0
- package/dist/pi-bin/server/server.py +451 -0
- package/dist/pi-bin/server/websocket.py +204 -0
- package/dist/server.js +10 -0
- package/dist/static/app.js +31 -8
- package/dist/static/index.html +1 -1
- package/dist/static/style.css +2 -2
- package/dist/ui-context.js +18 -14
- 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);
|
|
@@ -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)
|