pennyrouter 0.3.46 → 0.3.47
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 +34 -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/remote/rc_echo.py +298 -0
- package/standalone/remote/rc_penny.py +205 -0
- package/standalone/remote/rc_project.py +120 -0
- package/standalone/remote/rc_restart.py +144 -0
- package/standalone/remote/rc_tools.py +171 -0
- package/standalone/web-demo/claude-meter.mjs +18 -3
- package/standalone/web-demo/compare.html +23 -15
package/README.md
CHANGED
|
@@ -334,3 +334,37 @@ 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
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.47",
|
|
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
|
+
});
|
|
@@ -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)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Text-only Remote Control probe backed by one persistent Penny Claude process.
|
|
2
|
+
|
|
3
|
+
Uses the active Penny profile and an existing gateway. Tools, hooks, plugins, MCP,
|
|
4
|
+
and session persistence are disabled; conversation continuity lives in the child.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from rc_echo import BASE, EchoWorker, read_login
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_profile():
|
|
20
|
+
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
21
|
+
config = json.loads((config_root / "pennyrouter/config.json").read_text())
|
|
22
|
+
profile = config.get("profiles", {}).get(config.get("active_profile"), {})
|
|
23
|
+
if not profile.get("api_key") or not profile.get("gateway_base_url"):
|
|
24
|
+
raise RuntimeError("No active Penny profile with gateway and API key found.")
|
|
25
|
+
return profile, config.get("harnesses", {}).get("claude", {}).get("models", {}).get("sonnet", "claude-sonnet-5-5")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PennyClaude:
|
|
29
|
+
def __init__(self, profile, model, session_id, cwd, permission_handler=None):
|
|
30
|
+
self.profile, self.model, self.session_id, self.cwd = profile, model, session_id, cwd
|
|
31
|
+
self.process = None
|
|
32
|
+
self.stderr_task = None
|
|
33
|
+
self.permission_handler = permission_handler
|
|
34
|
+
self.effort = "low"
|
|
35
|
+
self.input_lock = asyncio.Lock()
|
|
36
|
+
self.interrupt_requested = False
|
|
37
|
+
self.persistence_dir = None
|
|
38
|
+
self.resume = False
|
|
39
|
+
|
|
40
|
+
async def start(self):
|
|
41
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("ANTHROPIC_")
|
|
42
|
+
and k not in {"CLAUDE_CODE_OAUTH_TOKEN", "CLAUDECODE", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"}}
|
|
43
|
+
env.update(ANTHROPIC_BASE_URL=self.profile["gateway_base_url"],
|
|
44
|
+
ANTHROPIC_API_KEY=self.profile["api_key"],
|
|
45
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
|
|
46
|
+
if self.persistence_dir:
|
|
47
|
+
env["CLAUDE_CONFIG_DIR"] = str(self.persistence_dir)
|
|
48
|
+
session_args = (["--resume", self.session_id] if self.resume else ["--session-id", self.session_id])
|
|
49
|
+
if not self.persistence_dir:
|
|
50
|
+
session_args.append("--no-session-persistence")
|
|
51
|
+
permissions = (["--permission-prompts", "host", "--permission-prompt-tool", "stdio",
|
|
52
|
+
"--permission-mode", "default", "--settings",
|
|
53
|
+
json.dumps({"permissions": {"ask": ["Read", "Edit"]}})]
|
|
54
|
+
if self.permission_handler else ["--permission-prompts", "none"])
|
|
55
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
56
|
+
"claude", "--bare", "-p", "--input-format", "stream-json", "--output-format", "stream-json",
|
|
57
|
+
"--verbose", "--include-partial-messages", *session_args,
|
|
58
|
+
"--model", self.model, "--effort", self.effort,
|
|
59
|
+
"--tools", "Read,Edit" if self.permission_handler else "", "--strict-mcp-config",
|
|
60
|
+
"--mcp-config", '{"mcpServers":{}}', *permissions,
|
|
61
|
+
"--disable-slash-commands", "--setting-sources", "",
|
|
62
|
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
63
|
+
cwd=self.cwd, env=env, limit=4 * 1024 * 1024)
|
|
64
|
+
# Drain diagnostic output so it cannot block the child, without logging credentials.
|
|
65
|
+
self.stderr_task = asyncio.create_task(self.drain_stderr())
|
|
66
|
+
if self.permission_handler:
|
|
67
|
+
# The SDK handshake activates the bidirectional permission channel.
|
|
68
|
+
request_id = self.session_id + "-init"
|
|
69
|
+
await self.send({"type": "control_request", "request_id": request_id,
|
|
70
|
+
"request": {"subtype": "initialize", "hooks": None}})
|
|
71
|
+
async with asyncio.timeout(60):
|
|
72
|
+
while True:
|
|
73
|
+
line = await self.process.stdout.readline()
|
|
74
|
+
if not line:
|
|
75
|
+
raise RuntimeError("Claude exited during permission-channel initialization.")
|
|
76
|
+
event = json.loads(line)
|
|
77
|
+
response = event.get("response", {})
|
|
78
|
+
if event.get("type") == "control_response" and response.get("request_id") == request_id:
|
|
79
|
+
if response.get("subtype") != "success":
|
|
80
|
+
raise RuntimeError("Claude refused permission-channel initialization.")
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
async def drain_stderr(self):
|
|
84
|
+
while await self.process.stderr.read(4096):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
async def send(self, payload):
|
|
88
|
+
async with self.input_lock:
|
|
89
|
+
self.process.stdin.write((json.dumps(payload) + "\n").encode())
|
|
90
|
+
await self.process.stdin.drain()
|
|
91
|
+
|
|
92
|
+
async def interrupt(self, request_id):
|
|
93
|
+
self.interrupt_requested = True
|
|
94
|
+
await self.send({"type": "control_request", "request_id": request_id,
|
|
95
|
+
"request": {"subtype": "interrupt"}})
|
|
96
|
+
|
|
97
|
+
async def turn(self, message):
|
|
98
|
+
self.interrupt_requested = False
|
|
99
|
+
if self.process is None:
|
|
100
|
+
await self.start()
|
|
101
|
+
content = message.get("content")
|
|
102
|
+
if not isinstance(content, str):
|
|
103
|
+
raise RuntimeError("This probe accepts text-only phone messages.")
|
|
104
|
+
await self.send({"type": "user", "message": {"role": "user", "content": content},
|
|
105
|
+
"session_id": self.session_id, "parent_tool_use_id": None})
|
|
106
|
+
async with asyncio.timeout(300 if self.permission_handler else 120):
|
|
107
|
+
while True:
|
|
108
|
+
line = await self.process.stdout.readline()
|
|
109
|
+
if not line:
|
|
110
|
+
raise RuntimeError("Penny Claude exited before completing the turn.")
|
|
111
|
+
event = json.loads(line)
|
|
112
|
+
kind = event.get("type")
|
|
113
|
+
if (kind == "result" and event.get("is_error") and self.interrupt_requested
|
|
114
|
+
and event.get("terminal_reason") == "aborted_streaming"):
|
|
115
|
+
# Claude's interrupted SDK turn carries an error flag even though
|
|
116
|
+
# the persistent process remains usable. Only an explicitly
|
|
117
|
+
# requested interrupt with this terminal reason is non-fatal.
|
|
118
|
+
event = {**event, "subtype": "success", "is_error": False,
|
|
119
|
+
"result": "Stopped by user."}
|
|
120
|
+
event.pop("errors", None)
|
|
121
|
+
if kind == "control_request" and self.permission_handler:
|
|
122
|
+
decision = await self.permission_handler(event)
|
|
123
|
+
await self.send({"type": "control_response", "response": {
|
|
124
|
+
"subtype": "success", "request_id": event["request_id"], "response": decision}})
|
|
125
|
+
continue
|
|
126
|
+
if kind == "control_response" and self.permission_handler:
|
|
127
|
+
yield event
|
|
128
|
+
if kind == "user" and isinstance(event.get("message", {}).get("content"), list):
|
|
129
|
+
yield event
|
|
130
|
+
if kind in {"stream_event", "assistant", "result"}:
|
|
131
|
+
yield event
|
|
132
|
+
if kind == "result":
|
|
133
|
+
if event.get("is_error"):
|
|
134
|
+
raise RuntimeError("Penny Claude reported a failed turn; see the phone result.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
async def close(self):
|
|
138
|
+
if self.process is not None and self.process.returncode is None:
|
|
139
|
+
self.process.terminate()
|
|
140
|
+
try:
|
|
141
|
+
await asyncio.wait_for(self.process.wait(), 5)
|
|
142
|
+
except asyncio.TimeoutError:
|
|
143
|
+
self.process.kill()
|
|
144
|
+
await self.process.wait()
|
|
145
|
+
if self.stderr_task is not None:
|
|
146
|
+
await self.stderr_task
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PennyWorker(EchoWorker):
|
|
150
|
+
def __init__(self, client, oauth, profile, model, cwd):
|
|
151
|
+
super().__init__(client, oauth)
|
|
152
|
+
self.title = "Penny live bridge test"
|
|
153
|
+
self.model = model
|
|
154
|
+
self.claude = PennyClaude(profile, model, self.local_session, cwd)
|
|
155
|
+
|
|
156
|
+
async def answer(self, payload):
|
|
157
|
+
async for event in self.claude.turn(payload["message"]):
|
|
158
|
+
event = {**event, "session_id": self.local_session}
|
|
159
|
+
event.setdefault("uuid", self.payload("unused")["uuid"])
|
|
160
|
+
if event["type"] == "result":
|
|
161
|
+
event["user_message_uuid"] = payload.get("uuid")
|
|
162
|
+
await self.events(event)
|
|
163
|
+
|
|
164
|
+
async def close(self):
|
|
165
|
+
try:
|
|
166
|
+
await self.claude.close()
|
|
167
|
+
finally:
|
|
168
|
+
await super().close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def run(seconds):
|
|
172
|
+
profile, model = read_profile()
|
|
173
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
174
|
+
"User-Agent": "penny-rc-text-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
175
|
+
# A failing health check never starts or restarts a gateway.
|
|
176
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
177
|
+
if response.status_code != 200:
|
|
178
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="penny-rc-text-") as cwd:
|
|
180
|
+
worker = PennyWorker(client, read_login(), profile, model, cwd)
|
|
181
|
+
tasks = []
|
|
182
|
+
try:
|
|
183
|
+
await worker.start()
|
|
184
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
185
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
186
|
+
for task in done:
|
|
187
|
+
task.result()
|
|
188
|
+
finally:
|
|
189
|
+
for task in tasks:
|
|
190
|
+
task.cancel()
|
|
191
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
192
|
+
await worker.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
197
|
+
parser.add_argument("--seconds", type=int, default=600)
|
|
198
|
+
args = parser.parse_args()
|
|
199
|
+
try:
|
|
200
|
+
asyncio.run(run(max(1, min(args.seconds, 1800))))
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
pass
|
|
203
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
204
|
+
print("Penny text probe failed; check gateway availability and Claude login. No gateway was restarted.")
|
|
205
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Experimental Penny project launcher with phone Read/Edit approvals and Stop.
|
|
2
|
+
|
|
3
|
+
Sessions persist after Ctrl+C. Resume is supported only at completed-turn boundaries.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shlex
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from rc_echo import EchoWorker, read_login
|
|
18
|
+
from rc_penny import read_profile
|
|
19
|
+
from rc_restart import RestartWorker
|
|
20
|
+
from rc_tools import ToolsWorker
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectWorker(RestartWorker, ToolsWorker):
|
|
24
|
+
def __init__(self, client, oauth, profile, model, directory, project):
|
|
25
|
+
super().__init__(client, oauth, profile, model, directory, project)
|
|
26
|
+
self.title = "Penny: " + Path(self.cwd).name
|
|
27
|
+
self.phase = "idle"
|
|
28
|
+
|
|
29
|
+
async def handle(self, envelope):
|
|
30
|
+
event_id = envelope.get("event_id")
|
|
31
|
+
is_new_user = (event_id and event_id not in self.seen and event_id not in self.queued_ids
|
|
32
|
+
and envelope.get("payload", {}).get("type") == "user")
|
|
33
|
+
if is_new_user:
|
|
34
|
+
self.phase = "busy"
|
|
35
|
+
self.save(self.phase)
|
|
36
|
+
await ToolsWorker.handle(self, envelope)
|
|
37
|
+
self.save(self.phase)
|
|
38
|
+
|
|
39
|
+
async def process_turns(self):
|
|
40
|
+
while not self.stopping:
|
|
41
|
+
envelope = await self.inbox.get()
|
|
42
|
+
self.active_turn = True
|
|
43
|
+
try:
|
|
44
|
+
await EchoWorker.handle(self, envelope)
|
|
45
|
+
self.queued_ids.discard(envelope["event_id"])
|
|
46
|
+
self.phase = "busy" if self.queued_ids else "idle"
|
|
47
|
+
self.save(self.phase)
|
|
48
|
+
print("Turn saved." if self.phase == "idle" else "Next turn queued.", flush=True)
|
|
49
|
+
finally:
|
|
50
|
+
self.active_turn = False
|
|
51
|
+
self.inbox.task_done()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def run(project, state_dir, effort):
|
|
55
|
+
directory = Path(state_dir).resolve()
|
|
56
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
with (directory / "lock").open("w") as lock:
|
|
58
|
+
try:
|
|
59
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
60
|
+
except BlockingIOError:
|
|
61
|
+
raise RuntimeError("This remote session is already running.") from None
|
|
62
|
+
profile, model = read_profile()
|
|
63
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
64
|
+
"User-Agent": "penny-rc-project/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
65
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
66
|
+
if response.status_code != 200:
|
|
67
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
68
|
+
worker = ProjectWorker(client, read_login(), profile, model, directory, project)
|
|
69
|
+
worker.claude.effort = effort
|
|
70
|
+
tasks = []
|
|
71
|
+
try:
|
|
72
|
+
await worker.start()
|
|
73
|
+
print("PROJECT " + worker.cwd, flush=True)
|
|
74
|
+
print("Read/Edit ask on your phone. Stop cancels the current turn. Ctrl+C disconnects.", flush=True)
|
|
75
|
+
print("Resume: penny remote --resume " + shlex.quote(str(directory)), flush=True)
|
|
76
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
|
|
77
|
+
asyncio.create_task(worker.process_turns())]
|
|
78
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
79
|
+
for task in done:
|
|
80
|
+
task.result()
|
|
81
|
+
finally:
|
|
82
|
+
for task in tasks:
|
|
83
|
+
task.cancel()
|
|
84
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
85
|
+
await worker.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
90
|
+
parser.add_argument("project", nargs="?", default=None, help="Project folder (defaults to current directory)")
|
|
91
|
+
parser.add_argument("--resume", metavar="STATE_FOLDER", help="Resume a saved Penny remote session")
|
|
92
|
+
parser.add_argument("--state-dir", help="State folder for a new session")
|
|
93
|
+
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
if args.resume and args.state_dir:
|
|
96
|
+
parser.error("Use --resume or --state-dir, not both.")
|
|
97
|
+
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
98
|
+
directory = args.resume or args.state_dir or str(config_root / "pennyrouter/remote" / str(uuid.uuid4()))
|
|
99
|
+
project = args.project
|
|
100
|
+
if args.resume:
|
|
101
|
+
state_path = Path(directory).expanduser() / "bridge.json"
|
|
102
|
+
if not state_path.is_file():
|
|
103
|
+
parser.error("No saved remote session found in that folder.")
|
|
104
|
+
saved = json.loads(state_path.read_text())
|
|
105
|
+
project = project or saved["cwd"]
|
|
106
|
+
else:
|
|
107
|
+
project = project or str(Path.cwd())
|
|
108
|
+
if (Path(directory).expanduser() / "bridge.json").exists():
|
|
109
|
+
parser.error("State folder already contains a session; use --resume.")
|
|
110
|
+
asyncio.run(run(Path(project).expanduser(), Path(directory).expanduser(), args.effort))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
try:
|
|
115
|
+
main()
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
print("Disconnected. The session remains available to resume.")
|
|
118
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError) as error:
|
|
119
|
+
print(str(error) if isinstance(error, RuntimeError) else "Remote session stopped after a network or timeout failure.")
|
|
120
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Completed-turn restart probe. State survives exit; credentials are never saved.
|
|
2
|
+
|
|
3
|
+
Run with a fresh --state-dir, send one phone message, then rerun the same command.
|
|
4
|
+
Tools are disabled. An unfinished turn blocks resume rather than being replayed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import fcntl
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from rc_echo import BASE, read_login
|
|
17
|
+
from rc_penny import PennyWorker, read_profile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RestartWorker(PennyWorker):
|
|
21
|
+
def __init__(self, client, oauth, profile, model, directory, project=None):
|
|
22
|
+
self.directory = Path(directory).resolve()
|
|
23
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
24
|
+
self.state_path = self.directory / "bridge.json"
|
|
25
|
+
cwd = Path(project).resolve() if project else self.directory / "workspace"
|
|
26
|
+
if project:
|
|
27
|
+
if not cwd.is_dir():
|
|
28
|
+
raise RuntimeError("Project folder does not exist.")
|
|
29
|
+
else:
|
|
30
|
+
cwd.mkdir(mode=0o700, exist_ok=True)
|
|
31
|
+
super().__init__(client, oauth, profile, model, str(cwd))
|
|
32
|
+
self.cwd = str(cwd)
|
|
33
|
+
self.title = "Penny restart recovery test"
|
|
34
|
+
self.completed = False
|
|
35
|
+
self.resuming = self.state_path.exists()
|
|
36
|
+
if self.resuming:
|
|
37
|
+
state = json.loads(self.state_path.read_text())
|
|
38
|
+
if state["phase"] != "idle":
|
|
39
|
+
raise RuntimeError("An unfinished turn was recorded. Resume is blocked to avoid replaying it.")
|
|
40
|
+
if state["model"] != model:
|
|
41
|
+
raise RuntimeError("Resume requires the original model configuration.")
|
|
42
|
+
if state.get("cwd", str(cwd)) != str(cwd):
|
|
43
|
+
raise RuntimeError("Resume requires the original project folder.")
|
|
44
|
+
self.session = state["remote_session"]
|
|
45
|
+
self.local_session = state["local_session"]
|
|
46
|
+
self.last_event_id = state["cursor"]
|
|
47
|
+
self.seen = set(state["seen"])
|
|
48
|
+
self.replies = state["replies"]
|
|
49
|
+
self.claude.session_id = self.local_session
|
|
50
|
+
self.claude.resume = self.replies > 0
|
|
51
|
+
self.claude.persistence_dir = self.directory / "claude"
|
|
52
|
+
self.claude.persistence_dir.mkdir(mode=0o700, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
def save(self, phase):
|
|
55
|
+
state = {"version": 1, "phase": phase, "remote_session": self.session,
|
|
56
|
+
"local_session": self.local_session, "model": self.model,
|
|
57
|
+
"cursor": self.last_event_id, "seen": sorted(self.seen), "replies": self.replies,
|
|
58
|
+
"cwd": self.cwd}
|
|
59
|
+
temporary = self.state_path.with_suffix(".tmp")
|
|
60
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
61
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
62
|
+
json.dump(state, handle)
|
|
63
|
+
handle.flush()
|
|
64
|
+
os.fsync(handle.fileno())
|
|
65
|
+
os.replace(temporary, self.state_path)
|
|
66
|
+
|
|
67
|
+
async def start(self):
|
|
68
|
+
if not self.resuming:
|
|
69
|
+
await super().start()
|
|
70
|
+
else:
|
|
71
|
+
bridge = await self.api("POST", self.path + "/bridge", {}, account=True)
|
|
72
|
+
if bridge["api_base_url"].rstrip("/") != BASE:
|
|
73
|
+
raise RuntimeError("Unexpected worker host; refusing to forward credentials.")
|
|
74
|
+
self.worker_token = bridge["worker_jwt"]
|
|
75
|
+
self.epoch = int(bridge["worker_epoch"])
|
|
76
|
+
await self.status("idle")
|
|
77
|
+
print(f"RESUMED https://claude.ai/code/{self.session}", flush=True)
|
|
78
|
+
self.save("idle")
|
|
79
|
+
|
|
80
|
+
async def handle(self, envelope):
|
|
81
|
+
is_turn = envelope.get("payload", {}).get("type") == "user" and envelope.get("event_id") not in self.seen
|
|
82
|
+
if is_turn:
|
|
83
|
+
self.save("busy")
|
|
84
|
+
await super().handle(envelope)
|
|
85
|
+
self.save("idle")
|
|
86
|
+
if is_turn:
|
|
87
|
+
self.completed = True
|
|
88
|
+
self.stopping = True
|
|
89
|
+
print("Completed turn saved. Restart this command to continue on the same phone link.", flush=True)
|
|
90
|
+
|
|
91
|
+
async def close(self):
|
|
92
|
+
self.stopping = True
|
|
93
|
+
await self.claude.close()
|
|
94
|
+
# Preserve the remote session instead of the disposable probes' archive policy.
|
|
95
|
+
if self.worker_token:
|
|
96
|
+
await self.api("PUT", self.path + "/worker", {
|
|
97
|
+
"worker_epoch": self.epoch, "connection_status": "disconnected"})
|
|
98
|
+
self.oauth = self.worker_token = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def run(directory, seconds):
|
|
102
|
+
directory = Path(directory).resolve()
|
|
103
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
104
|
+
with (directory / "lock").open("w") as lock:
|
|
105
|
+
try:
|
|
106
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
107
|
+
except BlockingIOError:
|
|
108
|
+
raise RuntimeError("Another restart probe is using this state folder.") from None
|
|
109
|
+
profile, model = read_profile()
|
|
110
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
111
|
+
"User-Agent": "penny-rc-restart-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
112
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
115
|
+
worker = RestartWorker(client, read_login(), profile, model, directory)
|
|
116
|
+
tasks = []
|
|
117
|
+
try:
|
|
118
|
+
await worker.start()
|
|
119
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
120
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
121
|
+
for task in done:
|
|
122
|
+
task.result()
|
|
123
|
+
if worker.completed:
|
|
124
|
+
# The stream cursor advances after handle() returns.
|
|
125
|
+
worker.save("idle")
|
|
126
|
+
finally:
|
|
127
|
+
for task in tasks:
|
|
128
|
+
task.cancel()
|
|
129
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
130
|
+
await worker.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
135
|
+
parser.add_argument("--state-dir", required=True)
|
|
136
|
+
parser.add_argument("--seconds", type=int, default=900)
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
try:
|
|
139
|
+
asyncio.run(run(args.state_dir, max(1, min(args.seconds, 1800))))
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
pass
|
|
142
|
+
except (RuntimeError, httpx.HTTPError) as error:
|
|
143
|
+
print(str(error) if isinstance(error, RuntimeError) else "Network request failed.")
|
|
144
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Remote file-tool approval probe confined to a disposable test folder.
|
|
2
|
+
|
|
3
|
+
Only Read and Edit are available; every call asks the phone. No shell, MCP,
|
|
4
|
+
permission-mode changes, or arbitrary control commands are forwarded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import copy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from rc_echo import EchoWorker, read_login
|
|
16
|
+
from rc_penny import PennyClaude, PennyWorker, read_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def confined_input(cwd, request, tool_input):
|
|
20
|
+
if request.get("tool_name") not in {"Read", "Edit"} or not isinstance(tool_input, dict):
|
|
21
|
+
return False
|
|
22
|
+
path = tool_input.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not path:
|
|
24
|
+
return False
|
|
25
|
+
root = Path(cwd).resolve()
|
|
26
|
+
target = Path(path)
|
|
27
|
+
if not target.is_absolute():
|
|
28
|
+
target = root / target
|
|
29
|
+
try:
|
|
30
|
+
return target.resolve().is_relative_to(root) and target.resolve() != root
|
|
31
|
+
except (OSError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolsWorker(PennyWorker):
|
|
36
|
+
def __init__(self, client, oauth, profile, model, cwd):
|
|
37
|
+
super().__init__(client, oauth, profile, model, cwd)
|
|
38
|
+
self.title = "Penny file approvals test"
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.tools = ["Read", "Edit"]
|
|
41
|
+
self.claude = PennyClaude(profile, model, self.local_session, cwd, self.permission)
|
|
42
|
+
self.pending_permissions = {}
|
|
43
|
+
self.permission_futures = {}
|
|
44
|
+
self.inbox = asyncio.Queue(maxsize=10)
|
|
45
|
+
self.queued_ids = set()
|
|
46
|
+
self.active_turn = False
|
|
47
|
+
self.disconnect_after_turns = 0
|
|
48
|
+
|
|
49
|
+
async def permission(self, event):
|
|
50
|
+
request = event.get("request", {})
|
|
51
|
+
denied = {"behavior": "deny", "message": "Only file operations inside this test folder are permitted."}
|
|
52
|
+
if request.get("subtype") != "can_use_tool" or not confined_input(self.cwd, request, request.get("input")):
|
|
53
|
+
return denied
|
|
54
|
+
request_id = event["request_id"]
|
|
55
|
+
future = asyncio.get_running_loop().create_future()
|
|
56
|
+
saved = copy.deepcopy(event)
|
|
57
|
+
self.pending_permissions[request_id] = saved
|
|
58
|
+
self.permission_futures[request_id] = future
|
|
59
|
+
print(f"Phone approval needed: {request['tool_name']}", flush=True)
|
|
60
|
+
try:
|
|
61
|
+
await self.events({**saved, "session_id": self.local_session, "uuid": self.payload("unused")["uuid"]})
|
|
62
|
+
async with asyncio.timeout(180):
|
|
63
|
+
decision = await future
|
|
64
|
+
if decision.get("behavior") == "allow":
|
|
65
|
+
tool_input = decision.get("updatedInput", request["input"])
|
|
66
|
+
if not confined_input(self.cwd, request, tool_input):
|
|
67
|
+
return denied
|
|
68
|
+
# Accept one tool decision, never persistent permission updates.
|
|
69
|
+
return {"behavior": "allow", "updatedInput": tool_input}
|
|
70
|
+
return {"behavior": "deny", "message": "The user denied this tool from the phone."}
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
return {"behavior": "deny", "message": "Phone approval timed out."}
|
|
73
|
+
finally:
|
|
74
|
+
self.pending_permissions.pop(request_id, None)
|
|
75
|
+
self.permission_futures.pop(request_id, None)
|
|
76
|
+
|
|
77
|
+
async def handle(self, envelope):
|
|
78
|
+
event_id = envelope.get("event_id")
|
|
79
|
+
if not event_id or event_id in self.seen or event_id in self.queued_ids:
|
|
80
|
+
return
|
|
81
|
+
payload = envelope.get("payload", {})
|
|
82
|
+
kind = payload.get("type")
|
|
83
|
+
if kind == "user":
|
|
84
|
+
self.queued_ids.add(event_id)
|
|
85
|
+
await self.inbox.put(envelope)
|
|
86
|
+
return
|
|
87
|
+
if kind == "control_response":
|
|
88
|
+
await self.delivery(event_id, "received")
|
|
89
|
+
response = payload.get("response", {})
|
|
90
|
+
request_id = response.get("request_id")
|
|
91
|
+
future = self.permission_futures.get(request_id)
|
|
92
|
+
if future is not None and not future.done():
|
|
93
|
+
decision = response.get("response", {}) if response.get("subtype") == "success" else {}
|
|
94
|
+
future.set_result(decision)
|
|
95
|
+
print(f"Phone decision: {decision.get('behavior', 'deny')}", flush=True)
|
|
96
|
+
else:
|
|
97
|
+
print("Ignored unmatched permission response", flush=True)
|
|
98
|
+
await self.delivery(event_id, "processed")
|
|
99
|
+
self.seen.add(event_id)
|
|
100
|
+
return
|
|
101
|
+
if kind == "control_request" and payload.get("request", {}).get("subtype") == "interrupt":
|
|
102
|
+
await self.delivery(event_id, "received")
|
|
103
|
+
for future in self.permission_futures.values():
|
|
104
|
+
if not future.done():
|
|
105
|
+
future.set_result({"behavior": "deny"})
|
|
106
|
+
if self.active_turn:
|
|
107
|
+
await self.claude.interrupt(payload["request_id"])
|
|
108
|
+
else:
|
|
109
|
+
await self.events(self.payload("control_response", response={
|
|
110
|
+
"subtype": "success", "request_id": payload.get("request_id"), "response": {}}))
|
|
111
|
+
await self.delivery(event_id, "processed")
|
|
112
|
+
self.seen.add(event_id)
|
|
113
|
+
return
|
|
114
|
+
await EchoWorker.handle(self, envelope)
|
|
115
|
+
|
|
116
|
+
async def process_turns(self):
|
|
117
|
+
while not self.stopping:
|
|
118
|
+
envelope = await self.inbox.get()
|
|
119
|
+
self.active_turn = True
|
|
120
|
+
try:
|
|
121
|
+
await EchoWorker.handle(self, envelope)
|
|
122
|
+
if self.disconnect_after_turns and self.replies == self.disconnect_after_turns:
|
|
123
|
+
self.force_reconnect.set()
|
|
124
|
+
finally:
|
|
125
|
+
self.active_turn = False
|
|
126
|
+
self.queued_ids.discard(envelope["event_id"])
|
|
127
|
+
self.inbox.task_done()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def run(seconds, effort="low", disconnect_after_turns=0):
|
|
131
|
+
profile, model = read_profile()
|
|
132
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
133
|
+
"User-Agent": "penny-rc-file-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
134
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
135
|
+
if response.status_code != 200:
|
|
136
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
137
|
+
with tempfile.TemporaryDirectory(prefix="penny-rc-files-") as cwd:
|
|
138
|
+
(Path(cwd) / "note.txt").write_text("original test content\n")
|
|
139
|
+
worker = ToolsWorker(client, read_login(), profile, model, cwd)
|
|
140
|
+
worker.claude.effort = effort
|
|
141
|
+
worker.disconnect_after_turns = disconnect_after_turns
|
|
142
|
+
tasks = []
|
|
143
|
+
try:
|
|
144
|
+
await worker.start()
|
|
145
|
+
print("TEST FOLDER " + cwd, flush=True)
|
|
146
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
|
|
147
|
+
asyncio.create_task(worker.process_turns())]
|
|
148
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
149
|
+
for task in done:
|
|
150
|
+
task.result()
|
|
151
|
+
finally:
|
|
152
|
+
for task in tasks:
|
|
153
|
+
task.cancel()
|
|
154
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
155
|
+
await worker.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
160
|
+
parser.add_argument("--seconds", type=int, default=900)
|
|
161
|
+
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
162
|
+
parser.add_argument("--disconnect-after-turns", type=int, default=0,
|
|
163
|
+
help="Simulate one three-second stream drop after this many completed turns")
|
|
164
|
+
args = parser.parse_args()
|
|
165
|
+
try:
|
|
166
|
+
asyncio.run(run(max(1, min(args.seconds, 1800)), args.effort, args.disconnect_after_turns))
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
pass
|
|
169
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
170
|
+
print("File approval probe stopped after a protocol, gateway, or timeout failure.")
|
|
171
|
+
raise SystemExit(1)
|
|
@@ -84,6 +84,10 @@ function emptyTotals() {
|
|
|
84
84
|
fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function emptySideCalls() {
|
|
88
|
+
return { count: 0, cost: 0, cache_read: 0, cache_write: 0, fresh_input: 0, output: 0, total: 0, models: {} };
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
export class ClaudeMeter {
|
|
88
92
|
constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
|
|
89
93
|
this.sessionId = sessionId;
|
|
@@ -106,7 +110,7 @@ export class ClaudeMeter {
|
|
|
106
110
|
this.transcriptPrints = new Map();
|
|
107
111
|
this.chargedPrints = new Map();
|
|
108
112
|
this.telemetry = new Map();
|
|
109
|
-
this.sideCalls =
|
|
113
|
+
this.sideCalls = emptySideCalls();
|
|
110
114
|
this.localReplyIds = new Set();
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -162,6 +166,11 @@ export class ClaudeMeter {
|
|
|
162
166
|
byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
|
|
163
167
|
this.sideCalls.count += 1;
|
|
164
168
|
this.sideCalls.cost += cost;
|
|
169
|
+
this.sideCalls.cache_read += read;
|
|
170
|
+
this.sideCalls.cache_write += write;
|
|
171
|
+
this.sideCalls.fresh_input += input;
|
|
172
|
+
this.sideCalls.output += output;
|
|
173
|
+
this.sideCalls.total += total;
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
@@ -287,11 +296,17 @@ export class ClaudeMeter {
|
|
|
287
296
|
} finally { await handle.close(); }
|
|
288
297
|
}
|
|
289
298
|
|
|
299
|
+
// `totals` is everything billed; `main` leaves out the side calls.
|
|
290
300
|
snapshot() {
|
|
301
|
+
const side = this.sideCalls;
|
|
302
|
+
const main = { ...this.totals, cache_read: this.totals.cache_read - side.cache_read,
|
|
303
|
+
cache_write: this.totals.cache_write - side.cache_write, write_5m: this.totals.write_5m - side.cache_write,
|
|
304
|
+
fresh_input: this.totals.fresh_input - side.fresh_input, output: this.totals.output - side.output,
|
|
305
|
+
total: this.totals.total - side.total, cost: this.totals.cost - side.cost };
|
|
291
306
|
return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
|
|
292
307
|
harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
|
|
293
308
|
transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
|
|
294
|
-
totals: { ...this.totals }, local_replies: this.localReplyIds.size,
|
|
309
|
+
totals: { ...this.totals }, main, local_replies: this.localReplyIds.size,
|
|
295
310
|
side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
|
|
296
311
|
}
|
|
297
312
|
}
|
|
@@ -454,7 +469,7 @@ export function galleryReport(left, right, { environment, harnessVersions }) {
|
|
|
454
469
|
const penny = sides.find((side) => side.role === 'PENNY');
|
|
455
470
|
if (!native || !penny || native.harness !== penny.harness) return null;
|
|
456
471
|
const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
|
|
457
|
-
side_calls: snapshot.side_calls ||
|
|
472
|
+
side_calls: snapshot.side_calls || emptySideCalls() });
|
|
458
473
|
return JSON.stringify({ version: 1, harness: native.harness,
|
|
459
474
|
harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
|
|
460
475
|
environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
.bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
|
|
32
32
|
.bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
|
|
33
33
|
#delta { color: var(--accent); }
|
|
34
|
+
.pane header #delta { margin-left: auto; white-space: nowrap; }
|
|
34
35
|
#versions { margin-left: auto; color: #92929c; font-size: 12px; }
|
|
35
36
|
#status { color: #b8b8c3; }
|
|
36
37
|
@media (max-width: 760px) { .bar { overflow-x: auto; } #versions { display: none; } }
|
|
@@ -84,7 +85,7 @@
|
|
|
84
85
|
.meter .field.read strong { color: var(--accent); }
|
|
85
86
|
.meter .field.write strong { color: var(--warn); }
|
|
86
87
|
.meter .field.in strong, .meter .field.out strong { color: #75ec45; }
|
|
87
|
-
.meter .field.side { color: #77777e; }
|
|
88
|
+
.meter .field.main, .meter .field.side { color: #77777e; }
|
|
88
89
|
.meter .wait { display: none; color: #77777e; }
|
|
89
90
|
.meter.waiting .wait { display: block; }
|
|
90
91
|
.meter.waiting .field { display: none; }
|
|
@@ -214,7 +215,6 @@
|
|
|
214
215
|
<button id="finish" disabled>Finish comparison</button>
|
|
215
216
|
<button id="download-logs" hidden>Download logs</button>
|
|
216
217
|
<button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
|
|
217
|
-
<span id="delta" aria-live="polite"></span>
|
|
218
218
|
<span id="versions"></span>
|
|
219
219
|
<span id="status">Connecting…</span>
|
|
220
220
|
</div>
|
|
@@ -225,6 +225,7 @@
|
|
|
225
225
|
<div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
226
226
|
</header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
|
|
227
227
|
<section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong><span class="chip" id="right-chip" hidden></span>
|
|
228
|
+
<span id="delta" aria-live="polite"></span>
|
|
228
229
|
<div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
|
|
229
230
|
<button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
|
|
230
231
|
<div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
@@ -801,6 +802,9 @@
|
|
|
801
802
|
// Same abbreviation as hud.py: 1.2k, 3.45M.
|
|
802
803
|
const abbreviate = (value) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M`
|
|
803
804
|
: value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value);
|
|
805
|
+
// Cents alone would round a cheap side call to $0.00.
|
|
806
|
+
const dollars = (value) => `$${value.toLocaleString('en-US',
|
|
807
|
+
{ minimumFractionDigits: value < 1 ? 4 : 2, maximumFractionDigits: value < 1 ? 4 : 2 })}`;
|
|
804
808
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
805
809
|
const meters = {};
|
|
806
810
|
const buildMeter = (side) => {
|
|
@@ -812,15 +816,16 @@
|
|
|
812
816
|
model.append(role, name);
|
|
813
817
|
root.append(model);
|
|
814
818
|
const amounts = {};
|
|
815
|
-
|
|
816
|
-
|
|
819
|
+
// Token rows count the main thread only. Background calls Claude Code makes outside the
|
|
820
|
+
// transcript (auto-mode reviewer, titles) show only as `side` cost; the last row is main + side.
|
|
821
|
+
for (const [key, label] of [['read', 'read'], ['write', 'write'], ['in', 'in'], ['out', 'out'],
|
|
822
|
+
['total', 'total'], ['main', 'main'], ['side', 'side'], ['cost', 'total']]) {
|
|
823
|
+
const field = document.createElement('span'); field.className = `field ${key}`;
|
|
817
824
|
field.append(document.createTextNode(`${label} `));
|
|
818
|
-
amounts[
|
|
819
|
-
field.append(amounts[
|
|
825
|
+
amounts[key] = document.createElement('strong');
|
|
826
|
+
field.append(amounts[key]);
|
|
820
827
|
root.append(field);
|
|
821
828
|
}
|
|
822
|
-
// Background calls (auto-mode reviewer, titles) that Claude Code makes outside the transcript.
|
|
823
|
-
amounts.side.parentElement.hidden = true;
|
|
824
829
|
// Zeros would read as a measurement; the pane has simply not seen a turn yet.
|
|
825
830
|
const wait = document.createElement('span'); wait.className = 'wait'; wait.textContent = 'waiting for first turn';
|
|
826
831
|
root.append(wait);
|
|
@@ -844,7 +849,7 @@
|
|
|
844
849
|
amounts.out.textContent = abbreviate(rolled.output);
|
|
845
850
|
amounts.total.textContent = abbreviate(rolled.cache_read + rolled.cache_write +
|
|
846
851
|
rolled.fresh_input + rolled.output);
|
|
847
|
-
amounts.cost.textContent =
|
|
852
|
+
amounts.cost.textContent = dollars(meter.cost);
|
|
848
853
|
if (Object.keys(rolled).some((key) => rolled[key] !== meter.targets[key])) settled = false;
|
|
849
854
|
}
|
|
850
855
|
if (settled) lastFrame = 0;
|
|
@@ -853,15 +858,15 @@
|
|
|
853
858
|
const showMeter = (snapshot) => {
|
|
854
859
|
const meter = meters[snapshot.side] ||= buildMeter(snapshot.side);
|
|
855
860
|
const { totals } = snapshot;
|
|
861
|
+
const main = snapshot.main || totals;
|
|
856
862
|
meter.role.textContent = snapshot.label || snapshot.role;
|
|
857
863
|
meter.name.textContent = (snapshot.model || 'waiting').split('/').pop();
|
|
858
|
-
meter.targets = { cache_read:
|
|
859
|
-
fresh_input:
|
|
864
|
+
meter.targets = { cache_read: main.cache_read, cache_write: main.cache_write,
|
|
865
|
+
fresh_input: main.fresh_input, output: main.output };
|
|
860
866
|
meter.cost = totals.cost;
|
|
861
867
|
const side = snapshot.side_calls;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (side?.count) meter.amounts.side.textContent = `${side.count} · $${side.cost.toFixed(2)}`;
|
|
868
|
+
meter.amounts.main.textContent = dollars(main.cost);
|
|
869
|
+
meter.amounts.side.textContent = dollars(side?.cost || 0);
|
|
865
870
|
meter.root.classList.add('has-data');
|
|
866
871
|
meter.root.classList.toggle('waiting', !totals.total);
|
|
867
872
|
if (!frameRequest) frameRequest = requestAnimationFrame(paintMeters);
|
|
@@ -870,8 +875,10 @@
|
|
|
870
875
|
const native = meterSnapshots.left?.totals;
|
|
871
876
|
const penny = meterSnapshots.right?.totals;
|
|
872
877
|
if (!native || !penny) return;
|
|
878
|
+
const nativeMain = meterSnapshots.left.main || native;
|
|
879
|
+
const pennyMain = meterSnapshots.right.main || penny;
|
|
873
880
|
const cost = native.cost ? (native.cost - penny.cost) / native.cost * 100 : 0;
|
|
874
|
-
const tokens =
|
|
881
|
+
const tokens = nativeMain.total ? (nativeMain.total - pennyMain.total) / nativeMain.total * 100 : 0;
|
|
875
882
|
$('delta').textContent =
|
|
876
883
|
`${cost > 0 ? '-' : '+'}${Math.abs(cost).toFixed(0)}% cost ${tokens > 0 ? '-' : '+'}${Math.abs(tokens).toFixed(0)}% tok`;
|
|
877
884
|
};
|
|
@@ -1159,6 +1166,7 @@
|
|
|
1159
1166
|
button.disabled = true;
|
|
1160
1167
|
button.textContent = 'Submitted to gallery';
|
|
1161
1168
|
}
|
|
1169
|
+
$('publish').hidden = true;
|
|
1162
1170
|
};
|
|
1163
1171
|
|
|
1164
1172
|
// Messages common to both servers
|