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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.47",
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": {
@@ -102,10 +102,10 @@ choose() {
102
102
  claude_models=(default opus sonnet haiku fable)
103
103
 
104
104
  # The current Codex catalog (apps/gateway/pennyrouter/main.py, _CODEX_NATIVE_MODELS), newest
105
- # first. That tuple also carries three PennyRouter-only aliases (gpt-penny-deepseek/glm/grok)
105
+ # first, without its legacy GPT-5.4 rows. That tuple also carries three PennyRouter-only aliases (gpt-penny-deepseek/glm/grok)
106
106
  # that route to other providers entirely; those are left out here since a native Codex pane would
107
107
  # reject them, making an "identical model on both sides" comparison impossible.
108
- codex_models=(default gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini)
108
+ codex_models=(default gpt-6.1-sol gpt-6-astra gpt-6-sol gpt-6-luna gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5)
109
109
 
110
110
  # OpenCode's configured default is the only portable alias; users can pass a full
111
111
  # provider/model id with --model when both panes must use a specific model.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "bifrost_datasheet",
3
- "generated_at": "2026-09-29T15:31:58+00:00",
3
+ "generated_at": "2026-10-04T20:39:28+00:00",
4
4
  "order": [
5
5
  "input",
6
6
  "cache_read",
@@ -1027,6 +1027,25 @@
1027
1027
  ]
1028
1028
  }
1029
1029
  },
1030
+ "gpt-6.1-sol": {
1031
+ "rates": [
1032
+ 2e-06,
1033
+ 1e-07,
1034
+ 2.5e-06,
1035
+ 2.5e-06,
1036
+ 1e-05
1037
+ ],
1038
+ "above": {
1039
+ "threshold": 272000,
1040
+ "rates": [
1041
+ 4e-06,
1042
+ 2e-07,
1043
+ 5e-06,
1044
+ 5e-06,
1045
+ 1.5e-05
1046
+ ]
1047
+ }
1048
+ },
1030
1049
  "gpt-daybreak-blue-latest": {
1031
1050
  "rates": [
1032
1051
  4e-06,
@@ -0,0 +1,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-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'], ['gpt-5.6-luna', 'GPT-5.6 Luna'],
1275
- ['gpt-5.5', 'GPT-5.5'], ['gpt-5.4', 'GPT-5.4'], ['gpt-5.4-mini', 'GPT-5.4 mini']],
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-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'];
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 ||