pennyrouter 0.3.51 → 0.3.52
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/package.json +1 -1
- package/standalone/web-demo/claude-meter.mjs +3 -3
- package/standalone/web-demo/compare.html +214 -10
- package/standalone/web-demo/gallery-submit.mjs +24 -0
- package/standalone/web-demo/server.mjs +34 -1
- package/standalone/remote/rc_echo.py +0 -298
- package/standalone/remote/rc_local.py +0 -110
- package/standalone/remote/rc_penny.py +0 -205
- package/standalone/remote/rc_project.py +0 -167
- package/standalone/remote/rc_restart.py +0 -145
- package/standalone/remote/rc_tools.py +0 -171
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
"""Experimental, text-only worker for Anthropic's captured Remote Control protocol.
|
|
2
|
-
|
|
3
|
-
No inference, tools, shell execution, or gateway changes. Uses a fresh session and
|
|
4
|
-
the current user's full-scope Claude login; credentials remain in memory. This is
|
|
5
|
-
an undocumented protocol probe, not supported native Claude Code integration.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import argparse
|
|
11
|
-
import asyncio
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
import time
|
|
18
|
-
import uuid
|
|
19
|
-
|
|
20
|
-
import httpx
|
|
21
|
-
|
|
22
|
-
BASE = "https://api.anthropic.com"
|
|
23
|
-
MODEL = "claude-sonnet-5-5"
|
|
24
|
-
REPLY = "Penny echo bridge received your phone message. No model or tools were used."
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def read_login() -> str:
|
|
28
|
-
"""Read an existing login without refreshing or persisting credentials."""
|
|
29
|
-
credentials = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude"))) / ".credentials.json"
|
|
30
|
-
if credentials.exists():
|
|
31
|
-
data = json.loads(credentials.read_text())
|
|
32
|
-
elif sys.platform == "darwin":
|
|
33
|
-
result = subprocess.run(
|
|
34
|
-
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
35
|
-
capture_output=True, text=True, check=False,
|
|
36
|
-
)
|
|
37
|
-
if result.returncode:
|
|
38
|
-
raise RuntimeError("Cannot read Claude login. Sign in with Claude Code /login first.")
|
|
39
|
-
data = json.loads(result.stdout)
|
|
40
|
-
else:
|
|
41
|
-
raise RuntimeError("No Claude login found. Sign in with Claude Code /login first.")
|
|
42
|
-
oauth = data.get("claudeAiOauth", {})
|
|
43
|
-
if oauth.get("expiresAt", 0) < (time.time() + 600) * 1000:
|
|
44
|
-
raise RuntimeError("Claude login expires too soon. Refresh it through Claude Code /login.")
|
|
45
|
-
token = oauth.get("accessToken", "")
|
|
46
|
-
if not token.startswith("sk-ant-oat"):
|
|
47
|
-
raise RuntimeError("A Claude subscription OAuth login is required.")
|
|
48
|
-
return token
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class EchoWorker:
|
|
52
|
-
def __init__(self, client: httpx.AsyncClient, oauth: str):
|
|
53
|
-
self.client = client
|
|
54
|
-
self.oauth = oauth
|
|
55
|
-
self.worker_token = ""
|
|
56
|
-
self.session = ""
|
|
57
|
-
self.local_session = str(uuid.uuid4())
|
|
58
|
-
self.epoch = 0
|
|
59
|
-
self.seen: set[str] = set()
|
|
60
|
-
self.stopping = False
|
|
61
|
-
self.replies = 0
|
|
62
|
-
self.title = "Penny echo bridge test"
|
|
63
|
-
self.model = MODEL
|
|
64
|
-
self.tools = []
|
|
65
|
-
self.cwd = "/private/tmp"
|
|
66
|
-
self.last_event_id = ""
|
|
67
|
-
self.force_reconnect = asyncio.Event()
|
|
68
|
-
self.stream_connections = 0
|
|
69
|
-
|
|
70
|
-
async def api(self, method: str, path: str, body=None, *, account=False):
|
|
71
|
-
# Retry only the worker operations, not account-level session creation.
|
|
72
|
-
# Event bodies keep their original UUIDs across retries for server deduplication.
|
|
73
|
-
for attempt in range(5):
|
|
74
|
-
try:
|
|
75
|
-
response = await self.client.request(method, BASE + path,
|
|
76
|
-
headers={"Authorization": "Bearer " + (self.oauth if account else self.worker_token)},
|
|
77
|
-
timeout=30,
|
|
78
|
-
**({"json": body} if body is not None else {}))
|
|
79
|
-
except httpx.TransportError:
|
|
80
|
-
if account or attempt == 4:
|
|
81
|
-
raise
|
|
82
|
-
else:
|
|
83
|
-
if account or response.status_code not in {429, 500, 502, 503, 504} or attempt == 4:
|
|
84
|
-
break
|
|
85
|
-
await asyncio.sleep(min(2 ** attempt, 8))
|
|
86
|
-
if not response.is_success:
|
|
87
|
-
# Never include raw bodies/headers: private API errors may contain credentials.
|
|
88
|
-
raise RuntimeError(f"{method} {path.rsplit('/', 1)[-1]} returned HTTP {response.status_code}")
|
|
89
|
-
return response.json() if response.content else {}
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def path(self):
|
|
93
|
-
return f"/v1/code/sessions/{self.session}"
|
|
94
|
-
|
|
95
|
-
def payload(self, kind, **fields):
|
|
96
|
-
return {"type": kind, "session_id": self.local_session, "uuid": str(uuid.uuid4()), **fields}
|
|
97
|
-
|
|
98
|
-
async def events(self, *payloads):
|
|
99
|
-
result = await self.api("POST", self.path + "/worker/events", {
|
|
100
|
-
"worker_epoch": self.epoch, "events": [{"payload": p} for p in payloads]})
|
|
101
|
-
print(f"Published {','.join(p['type'] for p in payloads)}; subscribers={result.get('has_subscribers')}", flush=True)
|
|
102
|
-
|
|
103
|
-
async def delivery(self, event_id, status):
|
|
104
|
-
await self.api("POST", self.path + "/worker/events/delivery", {
|
|
105
|
-
"worker_epoch": self.epoch, "updates": [{"event_id": event_id, "status": status}]})
|
|
106
|
-
|
|
107
|
-
async def status(self, status):
|
|
108
|
-
await self.api("PUT", self.path + "/worker", {
|
|
109
|
-
"worker_epoch": self.epoch, "worker_status": status,
|
|
110
|
-
"requires_action_details": None})
|
|
111
|
-
|
|
112
|
-
async def start(self):
|
|
113
|
-
result = await self.api("POST", "/v1/code/sessions", {
|
|
114
|
-
"title": self.title, "bridge": {}, "tags": ["remote-control-repl", "penny-echo-probe"],
|
|
115
|
-
"config": {"cwd": self.cwd, "model": self.model, "sources": [], "outcomes": [],
|
|
116
|
-
"reuse_outcome_branches": False}}, account=True)
|
|
117
|
-
self.session = result["session"]["id"]
|
|
118
|
-
bridge = await self.api("POST", self.path + "/bridge", {}, account=True)
|
|
119
|
-
if bridge["api_base_url"].rstrip("/") != BASE:
|
|
120
|
-
raise RuntimeError("Unexpected worker host; refusing to forward credentials.")
|
|
121
|
-
self.worker_token = bridge["worker_jwt"]
|
|
122
|
-
self.epoch = int(bridge["worker_epoch"])
|
|
123
|
-
await self.api("GET", self.path + "/worker")
|
|
124
|
-
await self.api("PUT", self.path + "/worker", {
|
|
125
|
-
"worker_epoch": self.epoch, "external_metadata": {"permission_mode": "default", "model": self.model}})
|
|
126
|
-
await self.status("idle")
|
|
127
|
-
await self.events(self.payload("system", subtype="init", cwd=self.cwd, tools=self.tools,
|
|
128
|
-
mcp_servers=[], model=self.model, permissionMode="default", apiKeySource="none",
|
|
129
|
-
output_style="default", agents=[], skills=[], plugins=[], slash_commands=[],
|
|
130
|
-
terminal_slash_commands=[], capabilities=[], analytics_disabled=True,
|
|
131
|
-
product_feedback_disabled=True))
|
|
132
|
-
print(f"OPEN https://claude.ai/code/{self.session}", flush=True)
|
|
133
|
-
|
|
134
|
-
async def handle(self, envelope):
|
|
135
|
-
event_id = envelope.get("event_id")
|
|
136
|
-
if not event_id or event_id in self.seen:
|
|
137
|
-
return
|
|
138
|
-
payload = envelope.get("payload", {})
|
|
139
|
-
kind = payload.get("type")
|
|
140
|
-
await self.delivery(event_id, "received")
|
|
141
|
-
print(f"Received {kind}" + (f"/{payload.get('request', {}).get('subtype')}" if kind == "control_request" else ""), flush=True)
|
|
142
|
-
if kind == "control_request":
|
|
143
|
-
request = payload.get("request", {})
|
|
144
|
-
subtype = request.get("subtype")
|
|
145
|
-
response = {"request_id": payload.get("request_id"), "subtype": "success"}
|
|
146
|
-
if subtype == "initialize":
|
|
147
|
-
response["response"] = {"commands": [], "agents": [], "output_style": "normal",
|
|
148
|
-
"available_output_styles": ["normal"], "models": [], "account": {},
|
|
149
|
-
"pid": os.getpid(), "inbound_read_receipts": True,
|
|
150
|
-
"current_model": self.model, "current_permission_mode": "default"}
|
|
151
|
-
response.update(pending_permission_requests=list(getattr(self, "pending_permissions", {}).values()),
|
|
152
|
-
pending_user_dialog_requests=[])
|
|
153
|
-
elif subtype == "end_session":
|
|
154
|
-
response["response"] = {}
|
|
155
|
-
self.stopping = True
|
|
156
|
-
else:
|
|
157
|
-
response.update(subtype="error", error="Echo probe supports only text messages and initialization.")
|
|
158
|
-
await self.events(self.payload("control_response", response=response))
|
|
159
|
-
elif kind == "user":
|
|
160
|
-
await self.delivery(event_id, "processing")
|
|
161
|
-
await self.status("running")
|
|
162
|
-
# Client-origin messages already exist in the remote transcript. Posting
|
|
163
|
-
# them again with a worker UUID creates a second visible input bubble.
|
|
164
|
-
await self.answer(payload)
|
|
165
|
-
await self.status("idle")
|
|
166
|
-
self.replies += 1
|
|
167
|
-
else:
|
|
168
|
-
raise RuntimeError("Unexpected inbound event type; stopping the echo probe.")
|
|
169
|
-
await self.delivery(event_id, "processed")
|
|
170
|
-
self.seen.add(event_id)
|
|
171
|
-
|
|
172
|
-
async def answer(self, payload):
|
|
173
|
-
message = {"id": "msg_" + uuid.uuid4().hex, "type": "message", "role": "assistant",
|
|
174
|
-
"model": MODEL, "content": [], "stop_reason": None,
|
|
175
|
-
"stop_sequence": None, "usage": {"input_tokens": 0, "output_tokens": 0}}
|
|
176
|
-
stream = [{"type": "message_start", "message": message},
|
|
177
|
-
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
178
|
-
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": REPLY}},
|
|
179
|
-
{"type": "content_block_stop", "index": 0},
|
|
180
|
-
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
|
181
|
-
"usage": {"output_tokens": 0}}, {"type": "message_stop"}]
|
|
182
|
-
await self.events(*(self.payload("stream_event", event=e, parent_tool_use_id=None) for e in stream))
|
|
183
|
-
await self.events(self.payload("assistant", parent_tool_use_id=None,
|
|
184
|
-
message={**message, "content": [{"type": "text", "text": REPLY}], "stop_reason": "end_turn"}),
|
|
185
|
-
self.payload("result", subtype="success", result=REPLY, is_error=False,
|
|
186
|
-
user_message_uuid=payload.get("uuid"), duration_ms=0, duration_api_ms=0,
|
|
187
|
-
total_cost_usd=0, usage={"input_tokens": 0, "output_tokens": 0}, modelUsage={},
|
|
188
|
-
permission_denials=[], num_turns=1, stop_reason="end_turn", terminal_reason="completed"))
|
|
189
|
-
|
|
190
|
-
async def heartbeat(self):
|
|
191
|
-
while not self.stopping:
|
|
192
|
-
result = await self.api("POST", self.path + "/worker/heartbeat", {
|
|
193
|
-
"session_id": self.session, "worker_epoch": self.epoch,
|
|
194
|
-
"supports_heartbeat_probe": False, "current_interval_seconds": 20, "idle_seconds": 0})
|
|
195
|
-
await asyncio.sleep(max(5, min(60, result.get("heartbeat_interval_seconds", 20))))
|
|
196
|
-
|
|
197
|
-
async def _listen_once(self):
|
|
198
|
-
headers = {"Authorization": "Bearer " + self.worker_token, "Accept": "text/event-stream"}
|
|
199
|
-
if self.last_event_id:
|
|
200
|
-
headers["Last-Event-ID"] = self.last_event_id
|
|
201
|
-
async with self.client.stream("GET", BASE + self.path + "/worker/events/stream",
|
|
202
|
-
headers=headers) as response:
|
|
203
|
-
if response.status_code != 200:
|
|
204
|
-
if response.status_code in {429, 500, 502, 503, 504}:
|
|
205
|
-
raise httpx.ReadError("Transient worker stream failure")
|
|
206
|
-
raise RuntimeError(f"Worker stream returned HTTP {response.status_code}")
|
|
207
|
-
self.stream_connections += 1
|
|
208
|
-
print(f"Worker stream connected #{self.stream_connections}; resuming={bool(self.last_event_id)}", flush=True)
|
|
209
|
-
data = []
|
|
210
|
-
frame_id = ""
|
|
211
|
-
async for line in response.aiter_lines():
|
|
212
|
-
if line.startswith("data:"):
|
|
213
|
-
data.append(line[5:].lstrip())
|
|
214
|
-
elif line.startswith("id:"):
|
|
215
|
-
frame_id = line[3:].strip()
|
|
216
|
-
elif not line and data:
|
|
217
|
-
await self.handle(json.loads("\n".join(data)))
|
|
218
|
-
if frame_id:
|
|
219
|
-
self.last_event_id = frame_id
|
|
220
|
-
data = []
|
|
221
|
-
frame_id = ""
|
|
222
|
-
if self.stopping:
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
async def listen(self):
|
|
226
|
-
failures = 0
|
|
227
|
-
while not self.stopping:
|
|
228
|
-
reader = asyncio.create_task(self._listen_once())
|
|
229
|
-
forced = asyncio.create_task(self.force_reconnect.wait())
|
|
230
|
-
cursor_before = self.last_event_id
|
|
231
|
-
try:
|
|
232
|
-
done, _ = await asyncio.wait([reader, forced], return_when=asyncio.FIRST_COMPLETED)
|
|
233
|
-
if forced in done:
|
|
234
|
-
self.force_reconnect.clear()
|
|
235
|
-
print("Simulating a three-second worker stream drop; keeping Claude alive", flush=True)
|
|
236
|
-
else:
|
|
237
|
-
try:
|
|
238
|
-
reader.result()
|
|
239
|
-
except httpx.TransportError:
|
|
240
|
-
pass
|
|
241
|
-
if self.stopping:
|
|
242
|
-
return
|
|
243
|
-
failures = 1 if self.last_event_id != cursor_before else failures + 1
|
|
244
|
-
if failures >= 5:
|
|
245
|
-
raise RuntimeError("Worker stream reconnect attempts exhausted.")
|
|
246
|
-
finally:
|
|
247
|
-
reader.cancel()
|
|
248
|
-
forced.cancel()
|
|
249
|
-
await asyncio.gather(reader, forced, return_exceptions=True)
|
|
250
|
-
await asyncio.sleep(3 if forced in done else min(2 ** (failures - 1), 8))
|
|
251
|
-
|
|
252
|
-
async def close(self):
|
|
253
|
-
self.stopping = True
|
|
254
|
-
if not self.session:
|
|
255
|
-
return
|
|
256
|
-
try:
|
|
257
|
-
if self.worker_token:
|
|
258
|
-
await self.api("PUT", self.path + "/worker", {
|
|
259
|
-
"worker_epoch": self.epoch, "connection_status": "disconnected"})
|
|
260
|
-
except (RuntimeError, httpx.HTTPError):
|
|
261
|
-
print("Worker disconnect update failed; attempting to archive the test session.", flush=True)
|
|
262
|
-
try:
|
|
263
|
-
await self.api("POST", self.path + "/archive", {}, account=True)
|
|
264
|
-
print(f"Archived test session; replies={self.replies}", flush=True)
|
|
265
|
-
except (RuntimeError, httpx.HTTPError):
|
|
266
|
-
print("Test session cleanup failed; archive Penny echo bridge test in Claude manually.", flush=True)
|
|
267
|
-
self.oauth = self.worker_token = ""
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
async def run(seconds):
|
|
271
|
-
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
272
|
-
"User-Agent": "penny-rc-echo-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
273
|
-
worker = EchoWorker(client, read_login())
|
|
274
|
-
tasks = []
|
|
275
|
-
try:
|
|
276
|
-
await worker.start()
|
|
277
|
-
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
278
|
-
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
279
|
-
for task in done:
|
|
280
|
-
task.result()
|
|
281
|
-
finally:
|
|
282
|
-
for task in tasks:
|
|
283
|
-
task.cancel()
|
|
284
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
285
|
-
await worker.close()
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if __name__ == "__main__":
|
|
289
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
290
|
-
parser.add_argument("--seconds", type=int, default=600, help="Test lifetime (default: 10 minutes)")
|
|
291
|
-
args = parser.parse_args()
|
|
292
|
-
try:
|
|
293
|
-
asyncio.run(run(max(1, min(args.seconds, 1800))))
|
|
294
|
-
except KeyboardInterrupt:
|
|
295
|
-
pass
|
|
296
|
-
except (RuntimeError, httpx.HTTPError) as error:
|
|
297
|
-
print(str(error) if isinstance(error, RuntimeError) else "Network request failed.", file=sys.stderr)
|
|
298
|
-
sys.exit(1)
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
"""Locate a completed native Claude transcript for an explicit terminal handoff."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import re
|
|
7
|
-
import uuid
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def claude_directory():
|
|
11
|
-
return Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude"))).expanduser().resolve()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def project_directory(config_dir, project):
|
|
15
|
-
return Path(config_dir) / "projects" / re.sub(r"[^a-zA-Z0-9]", "-", str(Path(project).resolve()))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def ensure_inactive(config_dir, session_id):
|
|
19
|
-
for path in (Path(config_dir) / "sessions").glob("*.json"):
|
|
20
|
-
try:
|
|
21
|
-
record = json.loads(path.read_text())
|
|
22
|
-
except (OSError, ValueError):
|
|
23
|
-
continue
|
|
24
|
-
if record.get("sessionId") != session_id:
|
|
25
|
-
continue
|
|
26
|
-
pid = record.get("pid")
|
|
27
|
-
if not isinstance(pid, int) or pid <= 0:
|
|
28
|
-
continue
|
|
29
|
-
try:
|
|
30
|
-
os.kill(pid, 0)
|
|
31
|
-
except ProcessLookupError:
|
|
32
|
-
continue
|
|
33
|
-
except PermissionError:
|
|
34
|
-
pass
|
|
35
|
-
raise RuntimeError("This Claude session is still running. Finish its turn and exit the local chat first.")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def read_local(project, session_id=None, config_dir=None):
|
|
39
|
-
config_dir = Path(config_dir or claude_directory()).resolve()
|
|
40
|
-
folder = project_directory(config_dir, project)
|
|
41
|
-
if session_id:
|
|
42
|
-
try:
|
|
43
|
-
session_id = str(uuid.UUID(session_id))
|
|
44
|
-
except ValueError:
|
|
45
|
-
raise RuntimeError("Local session ID must be a UUID.") from None
|
|
46
|
-
source = folder / (session_id + ".jsonl")
|
|
47
|
-
if not source.is_file():
|
|
48
|
-
raise RuntimeError("Local Claude session was not found in this project.")
|
|
49
|
-
else:
|
|
50
|
-
files = list(folder.glob("*.jsonl"))
|
|
51
|
-
if not files:
|
|
52
|
-
raise RuntimeError("No local Claude conversations were found for this project.")
|
|
53
|
-
source = max(files, key=lambda p: p.stat().st_mtime_ns)
|
|
54
|
-
session_id = source.stem
|
|
55
|
-
try:
|
|
56
|
-
uuid.UUID(session_id)
|
|
57
|
-
except ValueError:
|
|
58
|
-
raise RuntimeError("Latest local transcript has an invalid session ID.") from None
|
|
59
|
-
ensure_inactive(config_dir, session_id)
|
|
60
|
-
records = []
|
|
61
|
-
try:
|
|
62
|
-
with source.open() as handle:
|
|
63
|
-
for line in handle:
|
|
64
|
-
record = json.loads(line)
|
|
65
|
-
if not record.get("isSidechain"):
|
|
66
|
-
records.append(record)
|
|
67
|
-
except (OSError, ValueError):
|
|
68
|
-
raise RuntimeError("Cannot read a complete local Claude transcript.") from None
|
|
69
|
-
messages = [r for r in records if r.get("type") in {"user", "assistant"} and not r.get("isMeta")]
|
|
70
|
-
if not messages or messages[-1].get("type") != "assistant" or messages[-1].get("message", {}).get("stop_reason") not in {"end_turn", "stop_sequence", "max_tokens"}:
|
|
71
|
-
raise RuntimeError("The local conversation has no completed final turn. Finish it in Claude before handing it off.")
|
|
72
|
-
if any(r.get("sessionId") != session_id or Path(r.get("cwd", "")).resolve() != Path(project).resolve() for r in messages):
|
|
73
|
-
raise RuntimeError("Transcript session or project does not match the requested handoff.")
|
|
74
|
-
# Follow the last branch; older abandoned branches should not appear on the phone.
|
|
75
|
-
indexed = {r["uuid"]: r for r in records if r.get("uuid")}
|
|
76
|
-
current = messages[-1]
|
|
77
|
-
chain, visited = [], set()
|
|
78
|
-
while current:
|
|
79
|
-
record_id = current.get("uuid")
|
|
80
|
-
if not record_id or record_id in visited:
|
|
81
|
-
raise RuntimeError("Local transcript contains an invalid message chain.")
|
|
82
|
-
visited.add(record_id)
|
|
83
|
-
chain.append(current)
|
|
84
|
-
current = indexed.get(current.get("parentUuid"))
|
|
85
|
-
chain.reverse()
|
|
86
|
-
return {"session_id": session_id, "config_dir": str(config_dir),
|
|
87
|
-
"model": messages[-1]["message"].get("model"), "records": chain}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def phone_history(records, session_id):
|
|
91
|
-
"""Mirror visible text, retaining UUIDs. Native resume keeps the full local context."""
|
|
92
|
-
for record in records:
|
|
93
|
-
kind = record.get("type")
|
|
94
|
-
if kind not in {"user", "assistant"} or record.get("isMeta"):
|
|
95
|
-
continue
|
|
96
|
-
message = record.get("message", {})
|
|
97
|
-
content = message.get("content", [])
|
|
98
|
-
if isinstance(content, str):
|
|
99
|
-
text = content
|
|
100
|
-
else:
|
|
101
|
-
text = "\n".join(block.get("text", "") for block in content if block.get("type") == "text")
|
|
102
|
-
if not text.strip():
|
|
103
|
-
continue
|
|
104
|
-
clean = {"role": kind, "content": text if kind == "user" else [{"type": "text", "text": text}]}
|
|
105
|
-
if kind == "assistant":
|
|
106
|
-
clean.update(id=message.get("id", "msg_" + record["uuid"].replace("-", "")),
|
|
107
|
-
type="message", model=message.get("model"), stop_reason="end_turn",
|
|
108
|
-
stop_sequence=None, usage={"input_tokens": 0, "output_tokens": 0})
|
|
109
|
-
yield {"type": kind, "uuid": record["uuid"], "session_id": session_id,
|
|
110
|
-
"message": clean, "parent_tool_use_id": None}
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
"""Text-only Remote Control probe backed by one persistent Penny Claude process.
|
|
2
|
-
|
|
3
|
-
Uses the active Penny profile and an existing gateway. Tools, hooks, plugins, MCP,
|
|
4
|
-
and session persistence are disabled; conversation continuity lives in the child.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import argparse
|
|
8
|
-
import asyncio
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
import tempfile
|
|
13
|
-
|
|
14
|
-
import httpx
|
|
15
|
-
|
|
16
|
-
from rc_echo import BASE, EchoWorker, read_login
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def read_profile():
|
|
20
|
-
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
21
|
-
config = json.loads((config_root / "pennyrouter/config.json").read_text())
|
|
22
|
-
profile = config.get("profiles", {}).get(config.get("active_profile"), {})
|
|
23
|
-
if not profile.get("api_key") or not profile.get("gateway_base_url"):
|
|
24
|
-
raise RuntimeError("No active Penny profile with gateway and API key found.")
|
|
25
|
-
return profile, config.get("harnesses", {}).get("claude", {}).get("models", {}).get("sonnet", "claude-sonnet-5-5")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class PennyClaude:
|
|
29
|
-
def __init__(self, profile, model, session_id, cwd, permission_handler=None):
|
|
30
|
-
self.profile, self.model, self.session_id, self.cwd = profile, model, session_id, cwd
|
|
31
|
-
self.process = None
|
|
32
|
-
self.stderr_task = None
|
|
33
|
-
self.permission_handler = permission_handler
|
|
34
|
-
self.effort = "low"
|
|
35
|
-
self.input_lock = asyncio.Lock()
|
|
36
|
-
self.interrupt_requested = False
|
|
37
|
-
self.persistence_dir = None
|
|
38
|
-
self.resume = False
|
|
39
|
-
|
|
40
|
-
async def start(self):
|
|
41
|
-
env = {k: v for k, v in os.environ.items() if not k.startswith("ANTHROPIC_")
|
|
42
|
-
and k not in {"CLAUDE_CODE_OAUTH_TOKEN", "CLAUDECODE", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"}}
|
|
43
|
-
env.update(ANTHROPIC_BASE_URL=self.profile["gateway_base_url"],
|
|
44
|
-
ANTHROPIC_API_KEY=self.profile["api_key"],
|
|
45
|
-
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
|
|
46
|
-
if self.persistence_dir:
|
|
47
|
-
env["CLAUDE_CONFIG_DIR"] = str(self.persistence_dir)
|
|
48
|
-
session_args = (["--resume", self.session_id] if self.resume else ["--session-id", self.session_id])
|
|
49
|
-
if not self.persistence_dir:
|
|
50
|
-
session_args.append("--no-session-persistence")
|
|
51
|
-
permissions = (["--permission-prompts", "host", "--permission-prompt-tool", "stdio",
|
|
52
|
-
"--permission-mode", "default", "--settings",
|
|
53
|
-
json.dumps({"permissions": {"ask": ["Read", "Edit"]}})]
|
|
54
|
-
if self.permission_handler else ["--permission-prompts", "none"])
|
|
55
|
-
self.process = await asyncio.create_subprocess_exec(
|
|
56
|
-
"claude", "--bare", "-p", "--input-format", "stream-json", "--output-format", "stream-json",
|
|
57
|
-
"--verbose", "--include-partial-messages", *session_args,
|
|
58
|
-
"--model", self.model, "--effort", self.effort,
|
|
59
|
-
"--tools", "Read,Edit" if self.permission_handler else "", "--strict-mcp-config",
|
|
60
|
-
"--mcp-config", '{"mcpServers":{}}', *permissions,
|
|
61
|
-
"--disable-slash-commands", "--setting-sources", "",
|
|
62
|
-
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
63
|
-
cwd=self.cwd, env=env, limit=4 * 1024 * 1024)
|
|
64
|
-
# Drain diagnostic output so it cannot block the child, without logging credentials.
|
|
65
|
-
self.stderr_task = asyncio.create_task(self.drain_stderr())
|
|
66
|
-
if self.permission_handler:
|
|
67
|
-
# The SDK handshake activates the bidirectional permission channel.
|
|
68
|
-
request_id = self.session_id + "-init"
|
|
69
|
-
await self.send({"type": "control_request", "request_id": request_id,
|
|
70
|
-
"request": {"subtype": "initialize", "hooks": None}})
|
|
71
|
-
async with asyncio.timeout(60):
|
|
72
|
-
while True:
|
|
73
|
-
line = await self.process.stdout.readline()
|
|
74
|
-
if not line:
|
|
75
|
-
raise RuntimeError("Claude exited during permission-channel initialization.")
|
|
76
|
-
event = json.loads(line)
|
|
77
|
-
response = event.get("response", {})
|
|
78
|
-
if event.get("type") == "control_response" and response.get("request_id") == request_id:
|
|
79
|
-
if response.get("subtype") != "success":
|
|
80
|
-
raise RuntimeError("Claude refused permission-channel initialization.")
|
|
81
|
-
break
|
|
82
|
-
|
|
83
|
-
async def drain_stderr(self):
|
|
84
|
-
while await self.process.stderr.read(4096):
|
|
85
|
-
pass
|
|
86
|
-
|
|
87
|
-
async def send(self, payload):
|
|
88
|
-
async with self.input_lock:
|
|
89
|
-
self.process.stdin.write((json.dumps(payload) + "\n").encode())
|
|
90
|
-
await self.process.stdin.drain()
|
|
91
|
-
|
|
92
|
-
async def interrupt(self, request_id):
|
|
93
|
-
self.interrupt_requested = True
|
|
94
|
-
await self.send({"type": "control_request", "request_id": request_id,
|
|
95
|
-
"request": {"subtype": "interrupt"}})
|
|
96
|
-
|
|
97
|
-
async def turn(self, message):
|
|
98
|
-
self.interrupt_requested = False
|
|
99
|
-
if self.process is None:
|
|
100
|
-
await self.start()
|
|
101
|
-
content = message.get("content")
|
|
102
|
-
if not isinstance(content, str):
|
|
103
|
-
raise RuntimeError("This probe accepts text-only phone messages.")
|
|
104
|
-
await self.send({"type": "user", "message": {"role": "user", "content": content},
|
|
105
|
-
"session_id": self.session_id, "parent_tool_use_id": None})
|
|
106
|
-
async with asyncio.timeout(300 if self.permission_handler else 120):
|
|
107
|
-
while True:
|
|
108
|
-
line = await self.process.stdout.readline()
|
|
109
|
-
if not line:
|
|
110
|
-
raise RuntimeError("Penny Claude exited before completing the turn.")
|
|
111
|
-
event = json.loads(line)
|
|
112
|
-
kind = event.get("type")
|
|
113
|
-
if (kind == "result" and event.get("is_error") and self.interrupt_requested
|
|
114
|
-
and event.get("terminal_reason") == "aborted_streaming"):
|
|
115
|
-
# Claude's interrupted SDK turn carries an error flag even though
|
|
116
|
-
# the persistent process remains usable. Only an explicitly
|
|
117
|
-
# requested interrupt with this terminal reason is non-fatal.
|
|
118
|
-
event = {**event, "subtype": "success", "is_error": False,
|
|
119
|
-
"result": "Stopped by user."}
|
|
120
|
-
event.pop("errors", None)
|
|
121
|
-
if kind == "control_request" and self.permission_handler:
|
|
122
|
-
decision = await self.permission_handler(event)
|
|
123
|
-
await self.send({"type": "control_response", "response": {
|
|
124
|
-
"subtype": "success", "request_id": event["request_id"], "response": decision}})
|
|
125
|
-
continue
|
|
126
|
-
if kind == "control_response" and self.permission_handler:
|
|
127
|
-
yield event
|
|
128
|
-
if kind == "user" and isinstance(event.get("message", {}).get("content"), list):
|
|
129
|
-
yield event
|
|
130
|
-
if kind in {"stream_event", "assistant", "result"}:
|
|
131
|
-
yield event
|
|
132
|
-
if kind == "result":
|
|
133
|
-
if event.get("is_error"):
|
|
134
|
-
raise RuntimeError("Penny Claude reported a failed turn; see the phone result.")
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
async def close(self):
|
|
138
|
-
if self.process is not None and self.process.returncode is None:
|
|
139
|
-
self.process.terminate()
|
|
140
|
-
try:
|
|
141
|
-
await asyncio.wait_for(self.process.wait(), 5)
|
|
142
|
-
except asyncio.TimeoutError:
|
|
143
|
-
self.process.kill()
|
|
144
|
-
await self.process.wait()
|
|
145
|
-
if self.stderr_task is not None:
|
|
146
|
-
await self.stderr_task
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class PennyWorker(EchoWorker):
|
|
150
|
-
def __init__(self, client, oauth, profile, model, cwd):
|
|
151
|
-
super().__init__(client, oauth)
|
|
152
|
-
self.title = "Penny live bridge test"
|
|
153
|
-
self.model = model
|
|
154
|
-
self.claude = PennyClaude(profile, model, self.local_session, cwd)
|
|
155
|
-
|
|
156
|
-
async def answer(self, payload):
|
|
157
|
-
async for event in self.claude.turn(payload["message"]):
|
|
158
|
-
event = {**event, "session_id": self.local_session}
|
|
159
|
-
event.setdefault("uuid", self.payload("unused")["uuid"])
|
|
160
|
-
if event["type"] == "result":
|
|
161
|
-
event["user_message_uuid"] = payload.get("uuid")
|
|
162
|
-
await self.events(event)
|
|
163
|
-
|
|
164
|
-
async def close(self):
|
|
165
|
-
try:
|
|
166
|
-
await self.claude.close()
|
|
167
|
-
finally:
|
|
168
|
-
await super().close()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
async def run(seconds):
|
|
172
|
-
profile, model = read_profile()
|
|
173
|
-
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
174
|
-
"User-Agent": "penny-rc-text-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
175
|
-
# A failing health check never starts or restarts a gateway.
|
|
176
|
-
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
177
|
-
if response.status_code != 200:
|
|
178
|
-
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
179
|
-
with tempfile.TemporaryDirectory(prefix="penny-rc-text-") as cwd:
|
|
180
|
-
worker = PennyWorker(client, read_login(), profile, model, cwd)
|
|
181
|
-
tasks = []
|
|
182
|
-
try:
|
|
183
|
-
await worker.start()
|
|
184
|
-
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
185
|
-
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
186
|
-
for task in done:
|
|
187
|
-
task.result()
|
|
188
|
-
finally:
|
|
189
|
-
for task in tasks:
|
|
190
|
-
task.cancel()
|
|
191
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
192
|
-
await worker.close()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if __name__ == "__main__":
|
|
196
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
197
|
-
parser.add_argument("--seconds", type=int, default=600)
|
|
198
|
-
args = parser.parse_args()
|
|
199
|
-
try:
|
|
200
|
-
asyncio.run(run(max(1, min(args.seconds, 1800))))
|
|
201
|
-
except KeyboardInterrupt:
|
|
202
|
-
pass
|
|
203
|
-
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
204
|
-
print("Penny text probe failed; check gateway availability and Claude login. No gateway was restarted.")
|
|
205
|
-
raise SystemExit(1)
|