pennyrouter 0.3.46 → 0.3.48

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/README.md CHANGED
@@ -334,3 +334,60 @@ It does not delete PennyRouter accounts, credits, or remote API keys.
334
334
  ## License
335
335
 
336
336
  MIT
337
+
338
+ ### Experimental phone control
339
+
340
+ `penny remote [project-folder]` starts a fresh Claude phone session backed by
341
+ Penny. Keep the terminal running and open the printed `claude.ai/code` link on
342
+ your phone. Claude subscription login and a healthy configured Penny gateway
343
+ are required. This launcher uses an experimental, undocumented Remote Control
344
+ protocol and currently supports macOS and Linux with Python 3.11+ and `httpx`.
345
+ It does not start or restart a gateway.
346
+
347
+ For a packaged installation, set up the Python environment once:
348
+
349
+ ```sh
350
+ python3 -m venv ~/.config/pennyrouter/remote-venv
351
+ ~/.config/pennyrouter/remote-venv/bin/python -m pip install httpx
352
+ ```
353
+
354
+ Alternatively set `PENNY_REMOTE_PYTHON` to an existing Python 3.11+ environment
355
+ with `httpx`. Repository development uses the gateway virtual environment.
356
+
357
+ Only Read and Edit tools are enabled, confined to the selected project; each
358
+ operation asks for approval on the phone. Shell commands and file creation are
359
+ not supported yet. Stop interrupts the current turn. Ctrl+C disconnects while
360
+ preserving the phone session and local transcript. The launcher prints a
361
+ `penny remote --resume <state-folder>` command to reopen that same conversation.
362
+ Resume requires a completed turn; an unfinished checkpoint blocks replay.
363
+ Archive sessions you no longer need through Claude. Transcripts remain locally
364
+ in the printed state folder (by default `~/.config/pennyrouter/remote/<id>`).
365
+
366
+ From this repository, without updating the installed Penny command:
367
+
368
+ ```sh
369
+ node packages/pennyrouter-cli/bin/penny.js remote /path/to/project
370
+ ```
371
+
372
+ #### Hand off a terminal conversation
373
+
374
+ Finish the current turn and exit the local `penny claude` chat. From that same
375
+ project directory, run:
376
+
377
+ ```sh
378
+ penny remote --continue
379
+ ```
380
+
381
+ This selects the most recently saved local Claude session in that project.
382
+ Alternatively, `penny remote --local-session <UUID>` selects an exact session.
383
+ The bridge resumes the original native transcript and places its visible text
384
+ history in the new phone conversation. Tool results and reasoning remain in
385
+ Claude's native context; only visible chat text is mirrored into the phone UI.
386
+ An active local session or an unfinished final turn blocks the handoff.
387
+
388
+ To return to terminal chat, disconnect the bridge with Ctrl+C and run the
389
+ printed `penny claude --resume <UUID>` command from the same project. The phone
390
+ turns are saved in that original transcript. This is a sequential handoff;
391
+ the interactive terminal and the bridge cannot run the same session together.
392
+ The bridge continues to offer only its current Read/Edit tools, even when the
393
+ original terminal conversation used additional tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "start": "node ./bin/pennyrouter.js",
28
- "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
28
+ "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js src/remote.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
29
29
  },
30
30
  "dependencies": {
31
31
  "@xterm/addon-fit": "^0.10.0",
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { createInterface } from "node:readline/promises";
6
6
  import { stdin as input, stdout as output } from "node:process";
7
7
  import { aiderHarness } from "./harnesses/aider.js";
8
8
  import { runMcpServer } from "./mcp.js";
9
+ import { runPennyRemote } from "./remote.js";
9
10
  import { mcpStatus, registerMcp } from "./mcp-register.js";
10
11
 
11
12
  /** The harnesses that can run an MCP server. The other supported harnesses cannot. */
@@ -119,6 +120,10 @@ const PENNY_COMMAND_BY_HARNESS = Object.fromEntries(
119
120
  const LAUNCHABLE_HARNESS_IDS = Object.keys(PENNY_COMMAND_BY_HARNESS);
120
121
 
121
122
  export async function main(argv = process.argv.slice(2)) {
123
+ if (argv[0] === "remote") {
124
+ process.exitCode = await runPennyRemote(argv.slice(1));
125
+ return;
126
+ }
122
127
  const { command, flags } = parseArgs(argv);
123
128
 
124
129
  if (flags.help || command === "help") {
package/src/launch.js CHANGED
@@ -21,6 +21,7 @@ import { basename, dirname, join, resolve } from "node:path";
21
21
  import { randomUUID } from "node:crypto";
22
22
 
23
23
  import { configureClaudeCodeStatusline } from "./statusline.js";
24
+ import { runPennyRemote } from "./remote.js";
24
25
  import {
25
26
  fetchClaudeCodeModels,
26
27
  upgradeClaudeCodeModelValue,
@@ -273,6 +274,12 @@ async function copyRuntime() {
273
274
  // command so it can use the exact unnamespaced name in every Claude session.
274
275
  await rm(join(destination, "claude-plugin"), { recursive: true, force: true });
275
276
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
277
+ const remoteSource = join(source, "standalone", "remote");
278
+ if (await stat(remoteSource).then(() => true, () => false)) {
279
+ const remoteDestination = join(destination, "standalone", "remote");
280
+ await rm(remoteDestination, { recursive: true, force: true });
281
+ await cp(remoteSource, remoteDestination, { recursive: true, force: true });
282
+ }
276
283
  }
277
284
 
278
285
  // npx and local installs hoist dependencies above the package, so walk up like Node's resolver.
@@ -1555,7 +1562,8 @@ async function runPennyCompare(args) {
1555
1562
  export async function runPenny(argv = process.argv.slice(2)) {
1556
1563
  const harness = argv[0];
1557
1564
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
1558
- console.log("Usage: penny <claude|codex|opencode|compare> [arguments...]");
1565
+ console.log("Usage: penny <claude|codex|opencode|compare|remote> [arguments...]");
1566
+ console.log(" penny remote [project] opens an experimental phone-controlled session.");
1559
1567
  console.log(" penny compare launches Penny Compare; install it with `npx pennyrouter compare`.");
1560
1568
  console.log(" penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1561
1569
  console.log(" --private / --cloud select the like-named profile.");
@@ -1563,6 +1571,7 @@ export async function runPenny(argv = process.argv.slice(2)) {
1563
1571
  return 0;
1564
1572
  }
1565
1573
  if (harness === "compare") return runPennyCompare(argv.slice(1));
1574
+ if (harness === "remote") return runPennyRemote(argv.slice(1));
1566
1575
  if (!["claude", "codex", "opencode"].includes(harness)) {
1567
1576
  throw new Error(`Unknown Penny command "${harness}". Try penny claude, penny codex, penny opencode, or penny compare.`);
1568
1577
  }
package/src/remote.js ADDED
@@ -0,0 +1,40 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, resolve, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export function remoteRuntime(packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')) {
8
+ const repository = resolve(packageRoot, '../..');
9
+ const developmentScript = join(repository, 'scripts/interactive_probe/rc_project.py');
10
+ const developmentPython = join(repository, 'apps/gateway/.venv/bin/python');
11
+ const configRoot = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'pennyrouter');
12
+ const python = process.env.PENNY_REMOTE_PYTHON ||
13
+ (existsSync(developmentPython) ? developmentPython : join(configRoot, 'remote-venv/bin/python'));
14
+ return { python, script: existsSync(developmentScript) ? developmentScript :
15
+ join(packageRoot, 'standalone/remote/rc_project.py') };
16
+ }
17
+
18
+ export async function runPennyRemote(args) {
19
+ if (process.platform === 'win32') throw new Error('Penny remote currently requires macOS or Linux.');
20
+ const { python, script } = remoteRuntime();
21
+ if (!existsSync(script)) throw new Error('Penny remote runtime is missing. Refresh your Penny installation.');
22
+ const checked = spawnSync(python, ['-c', 'import sys; assert sys.version_info >= (3, 11); import httpx'],
23
+ { stdio: 'pipe' });
24
+ if (checked.error || checked.status !== 0) {
25
+ throw new Error('Penny remote needs Python 3.11+ with httpx. Create ~/.config/pennyrouter/remote-venv with python3 -m venv, then install httpx there; or set PENNY_REMOTE_PYTHON to an existing environment.');
26
+ }
27
+ console.log('Penny remote (experimental)');
28
+ const child = spawn(python, ['-u', script, ...args], { stdio: 'inherit' });
29
+ // Ctrl+C reaches both processes in a terminal. Let Python disconnect cleanly.
30
+ const onInterrupt = () => {};
31
+ process.on('SIGINT', onInterrupt);
32
+ try {
33
+ return await new Promise((done, fail) => {
34
+ child.once('error', fail);
35
+ child.once('exit', (code, signal) => done(code ?? (signal === 'SIGINT' ? 130 : 1)));
36
+ });
37
+ } finally {
38
+ process.removeListener('SIGINT', onInterrupt);
39
+ }
40
+ }
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { remoteRuntime } from './remote.js';
7
+
8
+ test('installed remote runtime uses packaged script and explicit Python', () => {
9
+ const before = process.env.PENNY_REMOTE_PYTHON;
10
+ process.env.PENNY_REMOTE_PYTHON = '/chosen/python';
11
+ try {
12
+ const runtime = remoteRuntime('/nonexistent/package');
13
+ assert.equal(runtime.python, '/chosen/python');
14
+ assert.equal(runtime.script, '/nonexistent/package/standalone/remote/rc_project.py');
15
+ } finally {
16
+ if (before === undefined) delete process.env.PENNY_REMOTE_PYTHON;
17
+ else process.env.PENNY_REMOTE_PYTHON = before;
18
+ }
19
+ });
20
+
21
+ test('repository checkout uses its probe and gateway environment', () => {
22
+ const folder = mkdtempSync(join(tmpdir(), 'penny-remote-runtime-'));
23
+ const before = process.env.PENNY_REMOTE_PYTHON;
24
+ delete process.env.PENNY_REMOTE_PYTHON;
25
+ try {
26
+ mkdirSync(join(folder, 'scripts/interactive_probe'), { recursive: true });
27
+ mkdirSync(join(folder, 'apps/gateway/.venv/bin'), { recursive: true });
28
+ writeFileSync(join(folder, 'scripts/interactive_probe/rc_project.py'), '');
29
+ writeFileSync(join(folder, 'apps/gateway/.venv/bin/python'), '');
30
+ const runtime = remoteRuntime(join(folder, 'packages/pennyrouter-cli'));
31
+ assert.equal(runtime.script, join(folder, 'scripts/interactive_probe/rc_project.py'));
32
+ assert.equal(runtime.python, join(folder, 'apps/gateway/.venv/bin/python'));
33
+ } finally {
34
+ rmSync(folder, { recursive: true, force: true });
35
+ if (before !== undefined) process.env.PENNY_REMOTE_PYTHON = before;
36
+ }
37
+ });
@@ -102,10 +102,10 @@ choose() {
102
102
  claude_models=(default opus sonnet haiku fable)
103
103
 
104
104
  # The current Codex catalog (apps/gateway/pennyrouter/main.py, _CODEX_NATIVE_MODELS), newest
105
- # first. That tuple also carries three PennyRouter-only aliases (gpt-penny-deepseek/glm/grok)
105
+ # first, without its legacy GPT-5.4 rows. That tuple also carries three PennyRouter-only aliases (gpt-penny-deepseek/glm/grok)
106
106
  # that route to other providers entirely; those are left out here since a native Codex pane would
107
107
  # reject them, making an "identical model on both sides" comparison impossible.
108
- codex_models=(default gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini)
108
+ codex_models=(default gpt-6.1-sol gpt-6-astra gpt-6-sol gpt-6-luna gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5)
109
109
 
110
110
  # OpenCode's configured default is the only portable alias; users can pass a full
111
111
  # provider/model id with --model when both panes must use a specific model.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "bifrost_datasheet",
3
- "generated_at": "2026-09-29T15:31:58+00:00",
3
+ "generated_at": "2026-10-04T20:39:28+00:00",
4
4
  "order": [
5
5
  "input",
6
6
  "cache_read",
@@ -1027,6 +1027,25 @@
1027
1027
  ]
1028
1028
  }
1029
1029
  },
1030
+ "gpt-6.1-sol": {
1031
+ "rates": [
1032
+ 2e-06,
1033
+ 1e-07,
1034
+ 2.5e-06,
1035
+ 2.5e-06,
1036
+ 1e-05
1037
+ ],
1038
+ "above": {
1039
+ "threshold": 272000,
1040
+ "rates": [
1041
+ 4e-06,
1042
+ 2e-07,
1043
+ 5e-06,
1044
+ 5e-06,
1045
+ 1.5e-05
1046
+ ]
1047
+ }
1048
+ },
1030
1049
  "gpt-daybreak-blue-latest": {
1031
1050
  "rates": [
1032
1051
  4e-06,
@@ -0,0 +1,298 @@
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)