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.
@@ -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}