pennyrouter 0.3.51 → 0.3.53
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 +6 -3
- package/standalone/web-demo/compare.html +289 -20
- package/standalone/web-demo/gallery-submit.mjs +24 -0
- package/standalone/web-demo/server.mjs +90 -22
- package/standalone/web-demo/session-transcripts.mjs +37 -0
- 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
|
@@ -9,11 +9,12 @@ import { promisify } from 'node:util';
|
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import pty from 'node-pty';
|
|
11
11
|
import { ClaudeMeter, CodexMeter, galleryReport, pricingProfiles, saveReport } from './claude-meter.mjs';
|
|
12
|
-
import { publishToGallery } from './gallery-submit.mjs';
|
|
12
|
+
import { publishToGallery, sendFeedback } from './gallery-submit.mjs';
|
|
13
13
|
import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './pane-options.mjs';
|
|
14
14
|
import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
15
15
|
import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
|
|
16
16
|
import { Readable } from 'node:stream';
|
|
17
|
+
import { bundleTranscripts, maxTranscriptBytes, transcriptBytes } from './session-transcripts.mjs';
|
|
17
18
|
|
|
18
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const compareDir = dirname(here);
|
|
@@ -46,6 +47,8 @@ const artifactSessions = new Map();
|
|
|
46
47
|
const telemetryMeters = new Map();
|
|
47
48
|
// Finished comparisons that may still be published, by one-time publish token.
|
|
48
49
|
const publishable = new Map();
|
|
50
|
+
// Each finished comparison's session transcript files, by the same token as its report.
|
|
51
|
+
const sessionTranscripts = new Map();
|
|
49
52
|
const publishWindowMs = 60 * 60_000;
|
|
50
53
|
// `npm start` runs from this package's folder; INIT_CWD is where it was invoked.
|
|
51
54
|
const launchDirectory = process.env.INIT_CWD || process.cwd();
|
|
@@ -67,6 +70,10 @@ const server = createServer(async (request, response) => {
|
|
|
67
70
|
await submitToGallery(request, response);
|
|
68
71
|
return;
|
|
69
72
|
}
|
|
73
|
+
if (url.pathname === '/feedback/submit') {
|
|
74
|
+
await submitFeedback(request, response);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
70
77
|
const telemetryMeter = telemetryMeters.get(url.pathname);
|
|
71
78
|
if (telemetryMeter && request.method === 'POST') {
|
|
72
79
|
try {
|
|
@@ -128,14 +135,56 @@ async function submitToGallery(request, response) {
|
|
|
128
135
|
const form = await new Request('http://localhost/gallery/submit', { method: 'POST',
|
|
129
136
|
headers: { 'content-type': request.headers['content-type'] || '' },
|
|
130
137
|
body: Readable.toWeb(request), duplex: 'half' }).formData();
|
|
138
|
+
await attachSessionTranscripts(form, token);
|
|
131
139
|
const result = await publishToGallery(report, form);
|
|
132
140
|
publishable.delete(token);
|
|
141
|
+
sessionTranscripts.delete(token);
|
|
133
142
|
reply(201, result);
|
|
134
143
|
} catch (error) {
|
|
135
144
|
reply(400, { error: error.message || 'Could not publish to the gallery.' });
|
|
136
145
|
}
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// When the submitter ticked "include transcripts", the page sends no file: the server bundles the
|
|
149
|
+
// two sessions it launched.
|
|
150
|
+
async function attachSessionTranscripts(form, token) {
|
|
151
|
+
if (form.get('include_transcript') !== '1') return;
|
|
152
|
+
const bundle = await bundleTranscripts(sessionTranscripts.get(token) ?? []);
|
|
153
|
+
if (!bundle) throw new Error('No session transcripts were found to include.');
|
|
154
|
+
if (bundle.size > maxTranscriptBytes) throw new Error('The session transcripts are over 10 MB.');
|
|
155
|
+
form.set('transcript', bundle, 'transcripts.jsonl');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function submitFeedback(request, response) {
|
|
159
|
+
const reply = (status, body) => response.writeHead(status, { 'Content-Type': 'application/json',
|
|
160
|
+
'Cache-Control': 'no-store' }).end(JSON.stringify(body));
|
|
161
|
+
const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
162
|
+
if (request.method !== 'POST' || !allowedOrigins.has(request.headers.origin)) {
|
|
163
|
+
reply(403, { error: 'Could not send the comment.' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const chunks = [];
|
|
168
|
+
let size = 0;
|
|
169
|
+
for await (const chunk of request) {
|
|
170
|
+
size += chunk.length;
|
|
171
|
+
if (size > 11 * 1024 * 1024) { reply(413, { error: 'The submission is too large.' }); return; }
|
|
172
|
+
chunks.push(chunk);
|
|
173
|
+
}
|
|
174
|
+
const form = await new Request('http://localhost/feedback/submit', { method: 'POST',
|
|
175
|
+
headers: { 'content-type': request.headers['content-type'] || '' },
|
|
176
|
+
body: Buffer.concat(chunks), duplex: 'half' }).formData();
|
|
177
|
+
const comment = form.get('comment');
|
|
178
|
+
if (typeof comment !== 'string' || !comment.trim()) { reply(400, { error: 'Write a comment first.' }); return; }
|
|
179
|
+
const token = request.headers.authorization?.replace(/^Bearer /, '');
|
|
180
|
+
const report = publishable.get(token) ?? '';
|
|
181
|
+
await attachSessionTranscripts(form, token);
|
|
182
|
+
reply(201, await sendFeedback(comment.trim(), report, form.get('transcript')));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
reply(400, { error: error.message || 'Could not send the comment.' });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
140
189
|
server.on('upgrade', (request, socket, head) => {
|
|
141
190
|
const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
@@ -267,6 +316,39 @@ wss.on('connection', (ws) => {
|
|
|
267
316
|
sessions.clear();
|
|
268
317
|
if (meterTimer) clearInterval(meterTimer);
|
|
269
318
|
};
|
|
319
|
+
let finishing = null;
|
|
320
|
+
// Reports the comparison once and stops its panes; resolves false when no report could be made.
|
|
321
|
+
const finishSession = () => finishing ??= (async () => {
|
|
322
|
+
try {
|
|
323
|
+
// Telemetry is exported every second; let the last batch land before closing the books.
|
|
324
|
+
if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
|
|
325
|
+
await pollMeters();
|
|
326
|
+
for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
|
|
327
|
+
const left = meters.get('left');
|
|
328
|
+
const right = meters.get('right');
|
|
329
|
+
if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
|
|
330
|
+
const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
|
|
331
|
+
const publishToken = report ? randomUUID() : null;
|
|
332
|
+
if (publishToken) {
|
|
333
|
+
publishable.set(publishToken, report);
|
|
334
|
+
const transcripts = [left, right].map((meter) => ({ role: meter.role, harness: meter.snapshot().harness,
|
|
335
|
+
files: meter.transcriptFiles }));
|
|
336
|
+
sessionTranscripts.set(publishToken, transcripts);
|
|
337
|
+
setTimeout(() => { publishable.delete(publishToken); sessionTranscripts.delete(publishToken); },
|
|
338
|
+
publishWindowMs).unref();
|
|
339
|
+
}
|
|
340
|
+
send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
|
|
341
|
+
gallery: publishToken ? { publishToken, transcriptBytes: await transcriptBytes(sessionTranscripts.get(publishToken)) } : null });
|
|
342
|
+
if (meterTimer) clearInterval(meterTimer);
|
|
343
|
+
for (const session of sessions.values()) {
|
|
344
|
+
try { session.kill(); } catch { /* already exited */ }
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
send({ type: 'report-error', error: error.message });
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
270
352
|
const handle = async (raw) => {
|
|
271
353
|
let message;
|
|
272
354
|
try { message = JSON.parse(raw); } catch { return; }
|
|
@@ -294,30 +376,16 @@ wss.on('connection', (ws) => {
|
|
|
294
376
|
}
|
|
295
377
|
return;
|
|
296
378
|
}
|
|
379
|
+
// "finish" freezes the comparison and reports it while the panes' transcripts stay on disk, so a
|
|
380
|
+
// Gallery submission or comment can still read them; "exit" then ends the server-side session.
|
|
381
|
+
if (message.type === 'finish') {
|
|
382
|
+
if (compare && !await finishSession()) { dispose(); ws.close(); }
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
297
385
|
if (message.type === 'exit') {
|
|
298
386
|
if (exiting) return;
|
|
299
387
|
exiting = true;
|
|
300
|
-
if (compare) {
|
|
301
|
-
try {
|
|
302
|
-
// Telemetry is exported every second; let the last batch land before closing the books.
|
|
303
|
-
if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
|
|
304
|
-
await pollMeters();
|
|
305
|
-
for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
|
|
306
|
-
const left = meters.get('left');
|
|
307
|
-
const right = meters.get('right');
|
|
308
|
-
if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
|
|
309
|
-
const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
|
|
310
|
-
const publishToken = report ? randomUUID() : null;
|
|
311
|
-
if (publishToken) {
|
|
312
|
-
publishable.set(publishToken, report);
|
|
313
|
-
setTimeout(() => publishable.delete(publishToken), publishWindowMs).unref();
|
|
314
|
-
}
|
|
315
|
-
send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
|
|
316
|
-
gallery: publishToken ? { publishToken } : null });
|
|
317
|
-
} catch (error) {
|
|
318
|
-
send({ type: 'report-error', error: error.message });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
388
|
+
if (compare && !await finishSession()) { /* the error was already reported */ }
|
|
321
389
|
dispose();
|
|
322
390
|
ws.close();
|
|
323
391
|
return;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
// The Gallery stores one transcript per submission. A finished comparison has two sessions, so
|
|
4
|
+
// they travel as a single JSONL file: a header line naming each session, then that session's
|
|
5
|
+
// own lines unchanged (the root transcript first, then any subagent or child transcripts).
|
|
6
|
+
export const maxTranscriptBytes = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const header = (role, harness, file) => `${JSON.stringify({ penny_compare_session: role, harness, file })}\n`;
|
|
9
|
+
|
|
10
|
+
async function sessionParts({ role, harness, files }) {
|
|
11
|
+
const parts = [];
|
|
12
|
+
for (const [index, path] of files.entries()) {
|
|
13
|
+
let text;
|
|
14
|
+
try { text = await readFile(path, 'utf8'); } catch { continue; }
|
|
15
|
+
if (!text) continue;
|
|
16
|
+
parts.push(header(role, harness, index === 0 ? 'root' : 'child'), text.endsWith('\n') ? text : `${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
return parts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The two sessions' transcripts as one JSONL blob, or null when neither left a readable file. */
|
|
22
|
+
export async function bundleTranscripts(sessions) {
|
|
23
|
+
const parts = (await Promise.all(sessions.map(sessionParts))).flat();
|
|
24
|
+
return parts.length ? new Blob(parts, { type: 'application/x-ndjson' }) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bytes the bundle will occupy, without reading the files (a file with no final newline gains one). */
|
|
28
|
+
export async function transcriptBytes(sessions) {
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const { role, harness, files } of sessions) {
|
|
31
|
+
for (const [index, path] of files.entries()) {
|
|
32
|
+
const info = await stat(path).catch(() => null);
|
|
33
|
+
if (info?.size) total += info.size + Buffer.byteLength(header(role, harness, index === 0 ? 'root' : 'child'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return total;
|
|
37
|
+
}
|
|
@@ -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}
|