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 +57 -0
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/launch.js +10 -1
- package/src/remote.js +40 -0
- package/src/remote.test.js +37 -0
- package/standalone/penny-compare +2 -2
- package/standalone/rates.json +20 -1
- package/standalone/remote/rc_echo.py +298 -0
- package/standalone/remote/rc_local.py +110 -0
- package/standalone/remote/rc_penny.py +205 -0
- package/standalone/remote/rc_project.py +167 -0
- package/standalone/remote/rc_restart.py +145 -0
- package/standalone/remote/rc_tools.py +171 -0
- package/standalone/web-demo/claude-meter.mjs +18 -3
- package/standalone/web-demo/compare.html +26 -17
- package/standalone/web-demo/server.mjs +2 -1
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.
|
|
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
|
+
});
|
package/standalone/penny-compare
CHANGED
|
@@ -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-
|
|
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.
|
package/standalone/rates.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "bifrost_datasheet",
|
|
3
|
-
"generated_at": "2026-
|
|
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)
|