pennyrouter 0.3.47 → 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 +23 -0
- package/package.json +1 -1
- package/standalone/penny-compare +2 -2
- package/standalone/rates.json +20 -1
- package/standalone/remote/rc_local.py +110 -0
- package/standalone/remote/rc_project.py +51 -4
- package/standalone/remote/rc_restart.py +2 -1
- package/standalone/web-demo/compare.html +3 -2
- package/standalone/web-demo/server.mjs +2 -1
package/README.md
CHANGED
|
@@ -368,3 +368,26 @@ From this repository, without updating the installed Penny command:
|
|
|
368
368
|
```sh
|
|
369
369
|
node packages/pennyrouter-cli/bin/penny.js remote /path/to/project
|
|
370
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
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,110 @@
|
|
|
1
|
+
"""Locate a completed native Claude transcript for an explicit terminal handoff."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def claude_directory():
|
|
11
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude"))).expanduser().resolve()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def project_directory(config_dir, project):
|
|
15
|
+
return Path(config_dir) / "projects" / re.sub(r"[^a-zA-Z0-9]", "-", str(Path(project).resolve()))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ensure_inactive(config_dir, session_id):
|
|
19
|
+
for path in (Path(config_dir) / "sessions").glob("*.json"):
|
|
20
|
+
try:
|
|
21
|
+
record = json.loads(path.read_text())
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
continue
|
|
24
|
+
if record.get("sessionId") != session_id:
|
|
25
|
+
continue
|
|
26
|
+
pid = record.get("pid")
|
|
27
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, 0)
|
|
31
|
+
except ProcessLookupError:
|
|
32
|
+
continue
|
|
33
|
+
except PermissionError:
|
|
34
|
+
pass
|
|
35
|
+
raise RuntimeError("This Claude session is still running. Finish its turn and exit the local chat first.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_local(project, session_id=None, config_dir=None):
|
|
39
|
+
config_dir = Path(config_dir or claude_directory()).resolve()
|
|
40
|
+
folder = project_directory(config_dir, project)
|
|
41
|
+
if session_id:
|
|
42
|
+
try:
|
|
43
|
+
session_id = str(uuid.UUID(session_id))
|
|
44
|
+
except ValueError:
|
|
45
|
+
raise RuntimeError("Local session ID must be a UUID.") from None
|
|
46
|
+
source = folder / (session_id + ".jsonl")
|
|
47
|
+
if not source.is_file():
|
|
48
|
+
raise RuntimeError("Local Claude session was not found in this project.")
|
|
49
|
+
else:
|
|
50
|
+
files = list(folder.glob("*.jsonl"))
|
|
51
|
+
if not files:
|
|
52
|
+
raise RuntimeError("No local Claude conversations were found for this project.")
|
|
53
|
+
source = max(files, key=lambda p: p.stat().st_mtime_ns)
|
|
54
|
+
session_id = source.stem
|
|
55
|
+
try:
|
|
56
|
+
uuid.UUID(session_id)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise RuntimeError("Latest local transcript has an invalid session ID.") from None
|
|
59
|
+
ensure_inactive(config_dir, session_id)
|
|
60
|
+
records = []
|
|
61
|
+
try:
|
|
62
|
+
with source.open() as handle:
|
|
63
|
+
for line in handle:
|
|
64
|
+
record = json.loads(line)
|
|
65
|
+
if not record.get("isSidechain"):
|
|
66
|
+
records.append(record)
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
raise RuntimeError("Cannot read a complete local Claude transcript.") from None
|
|
69
|
+
messages = [r for r in records if r.get("type") in {"user", "assistant"} and not r.get("isMeta")]
|
|
70
|
+
if not messages or messages[-1].get("type") != "assistant" or messages[-1].get("message", {}).get("stop_reason") not in {"end_turn", "stop_sequence", "max_tokens"}:
|
|
71
|
+
raise RuntimeError("The local conversation has no completed final turn. Finish it in Claude before handing it off.")
|
|
72
|
+
if any(r.get("sessionId") != session_id or Path(r.get("cwd", "")).resolve() != Path(project).resolve() for r in messages):
|
|
73
|
+
raise RuntimeError("Transcript session or project does not match the requested handoff.")
|
|
74
|
+
# Follow the last branch; older abandoned branches should not appear on the phone.
|
|
75
|
+
indexed = {r["uuid"]: r for r in records if r.get("uuid")}
|
|
76
|
+
current = messages[-1]
|
|
77
|
+
chain, visited = [], set()
|
|
78
|
+
while current:
|
|
79
|
+
record_id = current.get("uuid")
|
|
80
|
+
if not record_id or record_id in visited:
|
|
81
|
+
raise RuntimeError("Local transcript contains an invalid message chain.")
|
|
82
|
+
visited.add(record_id)
|
|
83
|
+
chain.append(current)
|
|
84
|
+
current = indexed.get(current.get("parentUuid"))
|
|
85
|
+
chain.reverse()
|
|
86
|
+
return {"session_id": session_id, "config_dir": str(config_dir),
|
|
87
|
+
"model": messages[-1]["message"].get("model"), "records": chain}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def phone_history(records, session_id):
|
|
91
|
+
"""Mirror visible text, retaining UUIDs. Native resume keeps the full local context."""
|
|
92
|
+
for record in records:
|
|
93
|
+
kind = record.get("type")
|
|
94
|
+
if kind not in {"user", "assistant"} or record.get("isMeta"):
|
|
95
|
+
continue
|
|
96
|
+
message = record.get("message", {})
|
|
97
|
+
content = message.get("content", [])
|
|
98
|
+
if isinstance(content, str):
|
|
99
|
+
text = content
|
|
100
|
+
else:
|
|
101
|
+
text = "\n".join(block.get("text", "") for block in content if block.get("type") == "text")
|
|
102
|
+
if not text.strip():
|
|
103
|
+
continue
|
|
104
|
+
clean = {"role": kind, "content": text if kind == "user" else [{"type": "text", "text": text}]}
|
|
105
|
+
if kind == "assistant":
|
|
106
|
+
clean.update(id=message.get("id", "msg_" + record["uuid"].replace("-", "")),
|
|
107
|
+
type="message", model=message.get("model"), stop_reason="end_turn",
|
|
108
|
+
stop_sequence=None, usage={"input_tokens": 0, "output_tokens": 0})
|
|
109
|
+
yield {"type": kind, "uuid": record["uuid"], "session_id": session_id,
|
|
110
|
+
"message": clean, "parent_tool_use_id": None}
|
|
@@ -18,13 +18,42 @@ from rc_echo import EchoWorker, read_login
|
|
|
18
18
|
from rc_penny import read_profile
|
|
19
19
|
from rc_restart import RestartWorker
|
|
20
20
|
from rc_tools import ToolsWorker
|
|
21
|
+
from rc_local import ensure_inactive, phone_history, read_local
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class ProjectWorker(RestartWorker, ToolsWorker):
|
|
24
|
-
def __init__(self, client, oauth, profile, model, directory, project):
|
|
25
|
+
def __init__(self, client, oauth, profile, model, directory, project, local=None):
|
|
25
26
|
super().__init__(client, oauth, profile, model, directory, project)
|
|
26
27
|
self.title = "Penny: " + Path(self.cwd).name
|
|
27
28
|
self.phase = "idle"
|
|
29
|
+
self.history = []
|
|
30
|
+
if self.resuming:
|
|
31
|
+
saved = json.loads(self.state_path.read_text())
|
|
32
|
+
if saved.get("native_config_dir"):
|
|
33
|
+
self.extra_state = {"native_config_dir": saved["native_config_dir"],
|
|
34
|
+
"history_synced": saved.get("history_synced", False)}
|
|
35
|
+
ensure_inactive(saved["native_config_dir"], self.local_session)
|
|
36
|
+
self.claude.persistence_dir = Path(saved["native_config_dir"])
|
|
37
|
+
self.claude.resume = True
|
|
38
|
+
if not self.extra_state["history_synced"]:
|
|
39
|
+
local = read_local(project, self.local_session, saved["native_config_dir"])
|
|
40
|
+
if local:
|
|
41
|
+
self.local_session = local["session_id"]
|
|
42
|
+
self.claude.session_id = self.local_session
|
|
43
|
+
self.claude.persistence_dir = Path(local["config_dir"])
|
|
44
|
+
self.claude.resume = True
|
|
45
|
+
self.extra_state = {"native_config_dir": local["config_dir"], "history_synced": False}
|
|
46
|
+
self.history = list(phone_history(local["records"], self.local_session))
|
|
47
|
+
|
|
48
|
+
async def start(self):
|
|
49
|
+
await super().start()
|
|
50
|
+
if self.extra_state and not self.extra_state["history_synced"]:
|
|
51
|
+
for offset in range(0, len(self.history), 20):
|
|
52
|
+
await self.events(*self.history[offset:offset + 20])
|
|
53
|
+
self.extra_state["history_synced"] = True
|
|
54
|
+
self.save("idle")
|
|
55
|
+
print("Local session: " + self.local_session, flush=True)
|
|
56
|
+
print("Return to terminal: penny claude --resume " + self.local_session, flush=True)
|
|
28
57
|
|
|
29
58
|
async def handle(self, envelope):
|
|
30
59
|
event_id = envelope.get("event_id")
|
|
@@ -51,7 +80,7 @@ class ProjectWorker(RestartWorker, ToolsWorker):
|
|
|
51
80
|
self.inbox.task_done()
|
|
52
81
|
|
|
53
82
|
|
|
54
|
-
async def run(project, state_dir, effort):
|
|
83
|
+
async def run(project, state_dir, effort, local_session=None, continue_local=False):
|
|
55
84
|
directory = Path(state_dir).resolve()
|
|
56
85
|
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
86
|
with (directory / "lock").open("w") as lock:
|
|
@@ -60,12 +89,23 @@ async def run(project, state_dir, effort):
|
|
|
60
89
|
except BlockingIOError:
|
|
61
90
|
raise RuntimeError("This remote session is already running.") from None
|
|
62
91
|
profile, model = read_profile()
|
|
92
|
+
local = None
|
|
93
|
+
state_path = directory / "bridge.json"
|
|
94
|
+
if local_session or continue_local:
|
|
95
|
+
if state_path.exists():
|
|
96
|
+
raise RuntimeError("Use a fresh state folder for a local session handoff.")
|
|
97
|
+
local = read_local(project, local_session)
|
|
98
|
+
model = local["model"] or model
|
|
99
|
+
elif state_path.exists():
|
|
100
|
+
saved = json.loads(state_path.read_text())
|
|
101
|
+
if saved.get("native_config_dir"):
|
|
102
|
+
model = saved["model"]
|
|
63
103
|
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
64
104
|
"User-Agent": "penny-rc-project/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
65
105
|
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
66
106
|
if response.status_code != 200:
|
|
67
107
|
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
68
|
-
worker = ProjectWorker(client, read_login(), profile, model, directory, project)
|
|
108
|
+
worker = ProjectWorker(client, read_login(), profile, model, directory, project, local)
|
|
69
109
|
worker.claude.effort = effort
|
|
70
110
|
tasks = []
|
|
71
111
|
try:
|
|
@@ -90,10 +130,16 @@ def main():
|
|
|
90
130
|
parser.add_argument("project", nargs="?", default=None, help="Project folder (defaults to current directory)")
|
|
91
131
|
parser.add_argument("--resume", metavar="STATE_FOLDER", help="Resume a saved Penny remote session")
|
|
92
132
|
parser.add_argument("--state-dir", help="State folder for a new session")
|
|
133
|
+
local_group = parser.add_mutually_exclusive_group()
|
|
134
|
+
local_group.add_argument("--continue", dest="continue_local", action="store_true",
|
|
135
|
+
help="Hand off the latest completed local Claude conversation in this project")
|
|
136
|
+
local_group.add_argument("--local-session", metavar="UUID", help="Hand off a specific local Claude session")
|
|
93
137
|
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
94
138
|
args = parser.parse_args()
|
|
95
139
|
if args.resume and args.state_dir:
|
|
96
140
|
parser.error("Use --resume or --state-dir, not both.")
|
|
141
|
+
if args.resume and (args.local_session or args.continue_local):
|
|
142
|
+
parser.error("Use --resume for a bridge session, or --continue/--local-session for a local handoff.")
|
|
97
143
|
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
98
144
|
directory = args.resume or args.state_dir or str(config_root / "pennyrouter/remote" / str(uuid.uuid4()))
|
|
99
145
|
project = args.project
|
|
@@ -107,7 +153,8 @@ def main():
|
|
|
107
153
|
project = project or str(Path.cwd())
|
|
108
154
|
if (Path(directory).expanduser() / "bridge.json").exists():
|
|
109
155
|
parser.error("State folder already contains a session; use --resume.")
|
|
110
|
-
asyncio.run(run(Path(project).expanduser(), Path(directory).expanduser(), args.effort
|
|
156
|
+
asyncio.run(run(Path(project).expanduser(), Path(directory).expanduser(), args.effort,
|
|
157
|
+
args.local_session, args.continue_local))
|
|
111
158
|
|
|
112
159
|
|
|
113
160
|
if __name__ == "__main__":
|
|
@@ -20,6 +20,7 @@ from rc_penny import PennyWorker, read_profile
|
|
|
20
20
|
class RestartWorker(PennyWorker):
|
|
21
21
|
def __init__(self, client, oauth, profile, model, directory, project=None):
|
|
22
22
|
self.directory = Path(directory).resolve()
|
|
23
|
+
self.extra_state = {}
|
|
23
24
|
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
24
25
|
self.state_path = self.directory / "bridge.json"
|
|
25
26
|
cwd = Path(project).resolve() if project else self.directory / "workspace"
|
|
@@ -55,7 +56,7 @@ class RestartWorker(PennyWorker):
|
|
|
55
56
|
state = {"version": 1, "phase": phase, "remote_session": self.session,
|
|
56
57
|
"local_session": self.local_session, "model": self.model,
|
|
57
58
|
"cursor": self.last_event_id, "seen": sorted(self.seen), "replies": self.replies,
|
|
58
|
-
"cwd": self.cwd}
|
|
59
|
+
"cwd": self.cwd, **self.extra_state}
|
|
59
60
|
temporary = self.state_path.with_suffix(".tmp")
|
|
60
61
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
61
62
|
with os.fdopen(descriptor, "w") as handle:
|
|
@@ -1271,8 +1271,9 @@
|
|
|
1271
1271
|
// ids), so the alias is what is sent; the label names the model it currently resolves to.
|
|
1272
1272
|
claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
|
|
1273
1273
|
['fable', 'Claude Fable 5.1']],
|
|
1274
|
-
codex: [['gpt-
|
|
1275
|
-
['gpt-
|
|
1274
|
+
codex: [['gpt-6.1-sol', 'GPT-6.1 Sol'], ['gpt-6-astra', 'GPT-6 Astra'], ['gpt-6-sol', 'GPT-6 Sol'],
|
|
1275
|
+
['gpt-6-luna', 'GPT-6 Luna'], ['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'],
|
|
1276
|
+
['gpt-5.6-luna', 'GPT-5.6 Luna'], ['gpt-5.5', 'GPT-5.5']],
|
|
1276
1277
|
};
|
|
1277
1278
|
$('left-variant').value = config.defaultHarness;
|
|
1278
1279
|
$('right-variant').value = `penny-${config.defaultHarness}`;
|
|
@@ -170,7 +170,8 @@ function validateEnvironment(environment = {}) {
|
|
|
170
170
|
const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
|
|
171
171
|
|
|
172
172
|
async function validateConfig(config) {
|
|
173
|
-
const codexModels = ['gpt-
|
|
173
|
+
const codexModels = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra',
|
|
174
|
+
'gpt-5.6-luna', 'gpt-5.5'];
|
|
174
175
|
const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
|
|
175
176
|
if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
|
|
176
177
|
config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||
|