pennyrouter 0.3.46 → 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}
@@ -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,167 @@
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
+ from rc_local import ensure_inactive, phone_history, read_local
22
+
23
+
24
+ class ProjectWorker(RestartWorker, ToolsWorker):
25
+ def __init__(self, client, oauth, profile, model, directory, project, local=None):
26
+ super().__init__(client, oauth, profile, model, directory, project)
27
+ self.title = "Penny: " + Path(self.cwd).name
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)
57
+
58
+ async def handle(self, envelope):
59
+ event_id = envelope.get("event_id")
60
+ is_new_user = (event_id and event_id not in self.seen and event_id not in self.queued_ids
61
+ and envelope.get("payload", {}).get("type") == "user")
62
+ if is_new_user:
63
+ self.phase = "busy"
64
+ self.save(self.phase)
65
+ await ToolsWorker.handle(self, envelope)
66
+ self.save(self.phase)
67
+
68
+ async def process_turns(self):
69
+ while not self.stopping:
70
+ envelope = await self.inbox.get()
71
+ self.active_turn = True
72
+ try:
73
+ await EchoWorker.handle(self, envelope)
74
+ self.queued_ids.discard(envelope["event_id"])
75
+ self.phase = "busy" if self.queued_ids else "idle"
76
+ self.save(self.phase)
77
+ print("Turn saved." if self.phase == "idle" else "Next turn queued.", flush=True)
78
+ finally:
79
+ self.active_turn = False
80
+ self.inbox.task_done()
81
+
82
+
83
+ async def run(project, state_dir, effort, local_session=None, continue_local=False):
84
+ directory = Path(state_dir).resolve()
85
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
86
+ with (directory / "lock").open("w") as lock:
87
+ try:
88
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
89
+ except BlockingIOError:
90
+ raise RuntimeError("This remote session is already running.") from None
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"]
103
+ async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
104
+ "User-Agent": "penny-rc-project/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
105
+ response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
106
+ if response.status_code != 200:
107
+ raise RuntimeError("Configured Penny gateway is not healthy.")
108
+ worker = ProjectWorker(client, read_login(), profile, model, directory, project, local)
109
+ worker.claude.effort = effort
110
+ tasks = []
111
+ try:
112
+ await worker.start()
113
+ print("PROJECT " + worker.cwd, flush=True)
114
+ print("Read/Edit ask on your phone. Stop cancels the current turn. Ctrl+C disconnects.", flush=True)
115
+ print("Resume: penny remote --resume " + shlex.quote(str(directory)), flush=True)
116
+ tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
117
+ asyncio.create_task(worker.process_turns())]
118
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
119
+ for task in done:
120
+ task.result()
121
+ finally:
122
+ for task in tasks:
123
+ task.cancel()
124
+ await asyncio.gather(*tasks, return_exceptions=True)
125
+ await worker.close()
126
+
127
+
128
+ def main():
129
+ parser = argparse.ArgumentParser(description=__doc__)
130
+ parser.add_argument("project", nargs="?", default=None, help="Project folder (defaults to current directory)")
131
+ parser.add_argument("--resume", metavar="STATE_FOLDER", help="Resume a saved Penny remote session")
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")
137
+ parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
138
+ args = parser.parse_args()
139
+ if args.resume and args.state_dir:
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.")
143
+ config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
144
+ directory = args.resume or args.state_dir or str(config_root / "pennyrouter/remote" / str(uuid.uuid4()))
145
+ project = args.project
146
+ if args.resume:
147
+ state_path = Path(directory).expanduser() / "bridge.json"
148
+ if not state_path.is_file():
149
+ parser.error("No saved remote session found in that folder.")
150
+ saved = json.loads(state_path.read_text())
151
+ project = project or saved["cwd"]
152
+ else:
153
+ project = project or str(Path.cwd())
154
+ if (Path(directory).expanduser() / "bridge.json").exists():
155
+ parser.error("State folder already contains a session; use --resume.")
156
+ asyncio.run(run(Path(project).expanduser(), Path(directory).expanduser(), args.effort,
157
+ args.local_session, args.continue_local))
158
+
159
+
160
+ if __name__ == "__main__":
161
+ try:
162
+ main()
163
+ except KeyboardInterrupt:
164
+ print("Disconnected. The session remains available to resume.")
165
+ except (RuntimeError, TimeoutError, httpx.HTTPError) as error:
166
+ print(str(error) if isinstance(error, RuntimeError) else "Remote session stopped after a network or timeout failure.")
167
+ raise SystemExit(1)
@@ -0,0 +1,145 @@
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.extra_state = {}
24
+ self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
25
+ self.state_path = self.directory / "bridge.json"
26
+ cwd = Path(project).resolve() if project else self.directory / "workspace"
27
+ if project:
28
+ if not cwd.is_dir():
29
+ raise RuntimeError("Project folder does not exist.")
30
+ else:
31
+ cwd.mkdir(mode=0o700, exist_ok=True)
32
+ super().__init__(client, oauth, profile, model, str(cwd))
33
+ self.cwd = str(cwd)
34
+ self.title = "Penny restart recovery test"
35
+ self.completed = False
36
+ self.resuming = self.state_path.exists()
37
+ if self.resuming:
38
+ state = json.loads(self.state_path.read_text())
39
+ if state["phase"] != "idle":
40
+ raise RuntimeError("An unfinished turn was recorded. Resume is blocked to avoid replaying it.")
41
+ if state["model"] != model:
42
+ raise RuntimeError("Resume requires the original model configuration.")
43
+ if state.get("cwd", str(cwd)) != str(cwd):
44
+ raise RuntimeError("Resume requires the original project folder.")
45
+ self.session = state["remote_session"]
46
+ self.local_session = state["local_session"]
47
+ self.last_event_id = state["cursor"]
48
+ self.seen = set(state["seen"])
49
+ self.replies = state["replies"]
50
+ self.claude.session_id = self.local_session
51
+ self.claude.resume = self.replies > 0
52
+ self.claude.persistence_dir = self.directory / "claude"
53
+ self.claude.persistence_dir.mkdir(mode=0o700, exist_ok=True)
54
+
55
+ def save(self, phase):
56
+ state = {"version": 1, "phase": phase, "remote_session": self.session,
57
+ "local_session": self.local_session, "model": self.model,
58
+ "cursor": self.last_event_id, "seen": sorted(self.seen), "replies": self.replies,
59
+ "cwd": self.cwd, **self.extra_state}
60
+ temporary = self.state_path.with_suffix(".tmp")
61
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
62
+ with os.fdopen(descriptor, "w") as handle:
63
+ json.dump(state, handle)
64
+ handle.flush()
65
+ os.fsync(handle.fileno())
66
+ os.replace(temporary, self.state_path)
67
+
68
+ async def start(self):
69
+ if not self.resuming:
70
+ await super().start()
71
+ else:
72
+ bridge = await self.api("POST", self.path + "/bridge", {}, account=True)
73
+ if bridge["api_base_url"].rstrip("/") != BASE:
74
+ raise RuntimeError("Unexpected worker host; refusing to forward credentials.")
75
+ self.worker_token = bridge["worker_jwt"]
76
+ self.epoch = int(bridge["worker_epoch"])
77
+ await self.status("idle")
78
+ print(f"RESUMED https://claude.ai/code/{self.session}", flush=True)
79
+ self.save("idle")
80
+
81
+ async def handle(self, envelope):
82
+ is_turn = envelope.get("payload", {}).get("type") == "user" and envelope.get("event_id") not in self.seen
83
+ if is_turn:
84
+ self.save("busy")
85
+ await super().handle(envelope)
86
+ self.save("idle")
87
+ if is_turn:
88
+ self.completed = True
89
+ self.stopping = True
90
+ print("Completed turn saved. Restart this command to continue on the same phone link.", flush=True)
91
+
92
+ async def close(self):
93
+ self.stopping = True
94
+ await self.claude.close()
95
+ # Preserve the remote session instead of the disposable probes' archive policy.
96
+ if self.worker_token:
97
+ await self.api("PUT", self.path + "/worker", {
98
+ "worker_epoch": self.epoch, "connection_status": "disconnected"})
99
+ self.oauth = self.worker_token = ""
100
+
101
+
102
+ async def run(directory, seconds):
103
+ directory = Path(directory).resolve()
104
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
105
+ with (directory / "lock").open("w") as lock:
106
+ try:
107
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
108
+ except BlockingIOError:
109
+ raise RuntimeError("Another restart probe is using this state folder.") from None
110
+ profile, model = read_profile()
111
+ async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
112
+ "User-Agent": "penny-rc-restart-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
113
+ response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
114
+ if response.status_code != 200:
115
+ raise RuntimeError("Configured Penny gateway is not healthy.")
116
+ worker = RestartWorker(client, read_login(), profile, model, directory)
117
+ tasks = []
118
+ try:
119
+ await worker.start()
120
+ tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
121
+ done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
122
+ for task in done:
123
+ task.result()
124
+ if worker.completed:
125
+ # The stream cursor advances after handle() returns.
126
+ worker.save("idle")
127
+ finally:
128
+ for task in tasks:
129
+ task.cancel()
130
+ await asyncio.gather(*tasks, return_exceptions=True)
131
+ await worker.close()
132
+
133
+
134
+ if __name__ == "__main__":
135
+ parser = argparse.ArgumentParser(description=__doc__)
136
+ parser.add_argument("--state-dir", required=True)
137
+ parser.add_argument("--seconds", type=int, default=900)
138
+ args = parser.parse_args()
139
+ try:
140
+ asyncio.run(run(args.state_dir, max(1, min(args.seconds, 1800))))
141
+ except KeyboardInterrupt:
142
+ pass
143
+ except (RuntimeError, httpx.HTTPError) as error:
144
+ print(str(error) if isinstance(error, RuntimeError) else "Network request failed.")
145
+ raise SystemExit(1)