session-peer 0.6.0__py3-none-any.whl
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.
session_peer.py
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""session-peer — message Claude Code and Codex sessions locally or over SSH.
|
|
3
|
+
|
|
4
|
+
Claude uses its native inbox socket/pipe; Codex uses its queue CLI. SSH runs the
|
|
5
|
+
same standard-library-only script on the destination, without a remote install.
|
|
6
|
+
Successful submission is not evidence of consumption or acknowledgement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
import getpass
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
import socket
|
|
21
|
+
import shutil
|
|
22
|
+
import sqlite3
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
import uuid
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
__version__ = "0.6.0"
|
|
31
|
+
GITHUB_REPO = "abruption/session-peer"
|
|
32
|
+
|
|
33
|
+
# Claude Code refuses a same-machine message once its serialized form passes
|
|
34
|
+
# about a million characters, so fail here rather than at the far end.
|
|
35
|
+
MAX_MESSAGE_CHARS = 1_000_000
|
|
36
|
+
|
|
37
|
+
# Over SSH the message travels as a command-line argument, so it meets Linux's
|
|
38
|
+
# MAX_ARG_STRLEN (128 KB per argument) long before the cap above. base64 costs
|
|
39
|
+
# 4/3, and the rest of the command needs room, so keep well under it.
|
|
40
|
+
MAX_REMOTE_MESSAGE_CHARS = 90_000
|
|
41
|
+
CONNECT_TIMEOUT = 10.0
|
|
42
|
+
DRAIN_TIMEOUT = 2.0
|
|
43
|
+
DETECT_TIMEOUT = 3.0
|
|
44
|
+
CODEX_QUEUE_TIMEOUT = 30.0
|
|
45
|
+
MAX_CODEX_MESSAGE_BYTES = 32 * 1024
|
|
46
|
+
|
|
47
|
+
# Tailscale hands out addresses from the CGNAT range, 100.64.0.0/10. Matching on
|
|
48
|
+
# "100." alone would also catch ordinary public addresses like 100.200.x.x.
|
|
49
|
+
TAILNET_SECOND_OCTET = range(64, 128)
|
|
50
|
+
|
|
51
|
+
EXIT_ERROR = 1
|
|
52
|
+
EXIT_NO_TARGET = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CcPeerError(Exception):
|
|
56
|
+
"""Anything the user should see as a one-line failure."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NoTargetError(CcPeerError):
|
|
60
|
+
"""A requested saved session cannot be resolved."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def codex_home(args: argparse.Namespace) -> Path:
|
|
64
|
+
return Path(getattr(args, "codex_home", None) or os.environ.get("CODEX_HOME")
|
|
65
|
+
or Path.home() / ".codex").expanduser().resolve()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def codex_executable(args: argparse.Namespace) -> str:
|
|
69
|
+
requested = getattr(args, "codex_bin", None) or "codex"
|
|
70
|
+
executable = shutil.which(os.path.expanduser(requested))
|
|
71
|
+
if executable is None:
|
|
72
|
+
raise CcPeerError(f"Codex executable not found: {requested!r}; set --codex-bin on the destination")
|
|
73
|
+
return str(Path(executable).absolute())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def discover_codex(args: argparse.Namespace) -> list[dict]:
|
|
77
|
+
"""Experimental saved-session discovery; never write Codex's internal DB."""
|
|
78
|
+
root = codex_home(args)
|
|
79
|
+
db = root / "state_5.sqlite"
|
|
80
|
+
if not db.is_file():
|
|
81
|
+
raise CcPeerError(f"Codex state_5.sqlite not found in {root}; check --codex-home (tested with CLI 0.154.0)")
|
|
82
|
+
try:
|
|
83
|
+
conn = sqlite3.connect(db.as_uri() + "?mode=ro", uri=True, timeout=3)
|
|
84
|
+
try:
|
|
85
|
+
conn.execute("PRAGMA query_only=ON")
|
|
86
|
+
columns = {row[1] for row in conn.execute("PRAGMA table_info(threads)")}
|
|
87
|
+
required = {"id", "title", "cwd", "updated_at", "archived", "rollout_path"}
|
|
88
|
+
if not required <= columns:
|
|
89
|
+
raise CcPeerError("Unsupported Codex threads schema; missing: " + ", ".join(sorted(required - columns)))
|
|
90
|
+
name = "COALESCE(NULLIF(name, ''), NULLIF(title, ''), id)" if "name" in columns else "COALESCE(NULLIF(title, ''), id)"
|
|
91
|
+
query = f"SELECT id, {name}, cwd, updated_at, archived FROM threads"
|
|
92
|
+
if not getattr(args, "all", False):
|
|
93
|
+
query += " WHERE archived = 0"
|
|
94
|
+
query += " ORDER BY updated_at DESC, id ASC"
|
|
95
|
+
return [{"agent": "codex", "id": r[0], "name": str(r[1]).splitlines()[0][:120], "cwd": r[2],
|
|
96
|
+
"updatedAt": r[3], "archived": bool(r[4])} for r in conn.execute(query)]
|
|
97
|
+
finally:
|
|
98
|
+
conn.close()
|
|
99
|
+
except sqlite3.Error as exc:
|
|
100
|
+
raise CcPeerError(f"Cannot read Codex state DB at {db}: {exc}") from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def codex_thread(target: str) -> str:
|
|
104
|
+
value = target.removeprefix("codex:")
|
|
105
|
+
if not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", value):
|
|
106
|
+
raise CcPeerError("Codex target must be codex:<full-thread-uuid>")
|
|
107
|
+
return str(uuid.UUID(value))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_codex_message(text: str) -> None:
|
|
111
|
+
size = len(text.encode("utf-8"))
|
|
112
|
+
if "\x00" in text:
|
|
113
|
+
raise CcPeerError("Codex messages cannot contain NUL characters (CLI argument limitation)")
|
|
114
|
+
if size > MAX_CODEX_MESSAGE_BYTES:
|
|
115
|
+
raise CcPeerError(f"Codex message is {size} UTF-8 bytes; session-peer limit is {MAX_CODEX_MESSAGE_BYTES}, including headers")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def queue_codex(args: argparse.Namespace, text: str) -> dict:
|
|
119
|
+
thread_id = codex_thread(args.to)
|
|
120
|
+
check_codex_message(text)
|
|
121
|
+
executable = codex_executable(args)
|
|
122
|
+
root = codex_home(args)
|
|
123
|
+
result = {"ok": True, "target": {"agent": "codex", "id": thread_id},
|
|
124
|
+
"chars": len(text), "dryRun": args.dry_run,
|
|
125
|
+
"status": "validated" if args.dry_run else "queued"}
|
|
126
|
+
if args.dry_run:
|
|
127
|
+
discovery_args = argparse.Namespace(codex_home=str(root), all=True)
|
|
128
|
+
if not any(s["id"] == thread_id for s in discover_codex(discovery_args)):
|
|
129
|
+
raise NoTargetError(f"No saved Codex thread {thread_id} in {root}")
|
|
130
|
+
return result
|
|
131
|
+
env = dict(os.environ, CODEX_HOME=str(root))
|
|
132
|
+
try:
|
|
133
|
+
done = subprocess.run([executable, "queue", "--thread", thread_id, "--message", text],
|
|
134
|
+
env=env, capture_output=True, encoding="utf-8", errors="replace",
|
|
135
|
+
timeout=CODEX_QUEUE_TIMEOUT)
|
|
136
|
+
except subprocess.TimeoutExpired as exc:
|
|
137
|
+
raise CcPeerError("Codex queue timed out; submission outcome unknown. Check the target queue before retrying.") from exc
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise CcPeerError(f"Could not execute Codex queue: {exc}") from exc
|
|
140
|
+
if done.returncode:
|
|
141
|
+
detail = (done.stderr.strip() or done.stdout.strip())[:2000]
|
|
142
|
+
raise CcPeerError(f"Codex queue failed (exit {done.returncode}, home {root}): {detail}")
|
|
143
|
+
match = re.search(r"^Queued message (\S+) for thread " + re.escape(thread_id) + r"\.$", done.stdout, re.MULTILINE)
|
|
144
|
+
if match:
|
|
145
|
+
result["queueId"] = match.group(1)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def codex_remote_options(args: argparse.Namespace) -> list[str]:
|
|
150
|
+
argv = []
|
|
151
|
+
for attribute, flag in (("codex_home", "--codex-home"), ("codex_bin", "--codex-bin")):
|
|
152
|
+
value = getattr(args, attribute, None)
|
|
153
|
+
if value:
|
|
154
|
+
argv.extend([flag, value])
|
|
155
|
+
return argv
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_codex(sessions: list[dict], where: str) -> str:
|
|
159
|
+
rows = [f"Saved Codex sessions on {where} (execution state unknown):", "THREAD NAME ARCHIVED CWD"]
|
|
160
|
+
rows.extend(f"{s['id']} {s['name']} {s['archived']} {s['cwd']}" for s in sessions)
|
|
161
|
+
return "\n".join(rows) if sessions else f"No saved Codex sessions on {where}."
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def codex_submission_text(result: dict, where: str) -> str:
|
|
165
|
+
if result["dryRun"]:
|
|
166
|
+
return f"Validated Codex thread {result['target']['id']} on {where}; nothing queued (submission not guaranteed)."
|
|
167
|
+
return f"Queued for Codex thread {result['target']['id']} on {where}; consumption not confirmed."
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
# Discovery. Runs on whichever machine owns the sessions — locally when there
|
|
172
|
+
# is no --host, inside the remote shell when there is.
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sessions_dir() -> Path:
|
|
177
|
+
"""Where Claude Code keeps its per-session records."""
|
|
178
|
+
for var in ("CLAUDE_CONFIG_DIR", "ANTHROPIC_CONFIG_DIR"):
|
|
179
|
+
root = os.environ.get(var)
|
|
180
|
+
if root:
|
|
181
|
+
return Path(root) / "sessions"
|
|
182
|
+
return Path.home() / ".claude" / "sessions"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def pid_alive(pid: int) -> bool:
|
|
189
|
+
if pid <= 1:
|
|
190
|
+
return False
|
|
191
|
+
if IS_WINDOWS:
|
|
192
|
+
import ctypes
|
|
193
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
194
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
195
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
196
|
+
if not handle:
|
|
197
|
+
return False
|
|
198
|
+
kernel32.CloseHandle(handle)
|
|
199
|
+
return True
|
|
200
|
+
try:
|
|
201
|
+
os.kill(pid, 0)
|
|
202
|
+
except ProcessLookupError:
|
|
203
|
+
return False
|
|
204
|
+
except PermissionError:
|
|
205
|
+
return True
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def discover(include_unreachable: bool = False) -> list[dict]:
|
|
210
|
+
"""List this machine's Claude Code sessions.
|
|
211
|
+
|
|
212
|
+
The socket path is read from each record, never guessed. It is not always
|
|
213
|
+
under /tmp: a session may bind under $XDG_RUNTIME_DIR, or under a private
|
|
214
|
+
per-user directory when Claude Code rejects the one it would have used.
|
|
215
|
+
"""
|
|
216
|
+
found: list[dict] = []
|
|
217
|
+
directory = sessions_dir()
|
|
218
|
+
if not directory.is_dir():
|
|
219
|
+
return found
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
entries = sorted(directory.glob("*.json"))
|
|
223
|
+
except PermissionError:
|
|
224
|
+
return found
|
|
225
|
+
for record_file in entries:
|
|
226
|
+
if not record_file.stem.isdigit():
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
record = json.loads(record_file.read_text(encoding="utf-8"))
|
|
230
|
+
except (OSError, ValueError):
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
pid = record.get("pid")
|
|
234
|
+
if not isinstance(pid, int):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
sock = record.get("messagingSocketPath") or ""
|
|
238
|
+
alive = pid_alive(pid)
|
|
239
|
+
if IS_WINDOWS:
|
|
240
|
+
has_inbox = bool(sock) and sock.startswith("\\\\.\\pipe\\")
|
|
241
|
+
else:
|
|
242
|
+
has_inbox = bool(sock) and Path(sock).is_socket()
|
|
243
|
+
|
|
244
|
+
entry = {
|
|
245
|
+
"pid": pid,
|
|
246
|
+
"name": record.get("name"),
|
|
247
|
+
"status": record.get("status"),
|
|
248
|
+
"cwd": record.get("cwd"),
|
|
249
|
+
"kind": record.get("kind"),
|
|
250
|
+
"version": record.get("version"),
|
|
251
|
+
"tmux": record.get("tmux"),
|
|
252
|
+
"socket": sock,
|
|
253
|
+
"alive": alive,
|
|
254
|
+
"reachable": alive and has_inbox,
|
|
255
|
+
}
|
|
256
|
+
if entry["reachable"] or include_unreachable:
|
|
257
|
+
found.append(entry)
|
|
258
|
+
|
|
259
|
+
return found
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def resolve_target(sessions: list[dict], target: str) -> dict:
|
|
263
|
+
"""Find one session by name, or by pid when the target is all digits."""
|
|
264
|
+
reachable = [s for s in sessions if s["reachable"]]
|
|
265
|
+
|
|
266
|
+
if target.isdigit():
|
|
267
|
+
matches = [s for s in reachable if s["pid"] == int(target)]
|
|
268
|
+
else:
|
|
269
|
+
wanted = target.casefold()
|
|
270
|
+
matches = [s for s in reachable if (s["name"] or "").casefold() == wanted]
|
|
271
|
+
|
|
272
|
+
if not matches:
|
|
273
|
+
known = ", ".join(sorted(s["name"] or str(s["pid"]) for s in reachable))
|
|
274
|
+
raise CcPeerError(
|
|
275
|
+
f"no reachable session named {target!r}"
|
|
276
|
+
+ (f" (reachable: {known})" if known else " (no reachable sessions)")
|
|
277
|
+
)
|
|
278
|
+
if len(matches) > 1:
|
|
279
|
+
pids = ", ".join(str(s["pid"]) for s in matches)
|
|
280
|
+
raise CcPeerError(
|
|
281
|
+
f"{len(matches)} sessions answer to {target!r} (pids: {pids}) — "
|
|
282
|
+
f"address one by pid instead"
|
|
283
|
+
)
|
|
284
|
+
return matches[0]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def check_message(text: str, remote: bool) -> None:
|
|
288
|
+
"""Reject a message that can't be delivered, before anything is sent.
|
|
289
|
+
|
|
290
|
+
Kept out of post_to_socket so --dry-run and the remote path get the same
|
|
291
|
+
answer as a real send: a rehearsal that passes and a send that fails is
|
|
292
|
+
worse than no rehearsal.
|
|
293
|
+
"""
|
|
294
|
+
if not text.strip():
|
|
295
|
+
raise CcPeerError("refusing to send an empty message")
|
|
296
|
+
if len(text) > MAX_MESSAGE_CHARS:
|
|
297
|
+
raise CcPeerError(
|
|
298
|
+
f"message is {len(text)} characters; the limit is {MAX_MESSAGE_CHARS}"
|
|
299
|
+
)
|
|
300
|
+
if remote and len(text) > MAX_REMOTE_MESSAGE_CHARS:
|
|
301
|
+
raise CcPeerError(
|
|
302
|
+
f"message is {len(text)} characters; over SSH the limit is "
|
|
303
|
+
f"{MAX_REMOTE_MESSAGE_CHARS}, because it travels as a command-line "
|
|
304
|
+
f"argument. Send it from a session on that machine to use the full "
|
|
305
|
+
f"{MAX_MESSAGE_CHARS}."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _read_win_auth(pid: int) -> str | None:
|
|
310
|
+
"""Read the Windows auth key for a session and return the auth JSON line."""
|
|
311
|
+
directory = sessions_dir()
|
|
312
|
+
for key_file in directory.glob(f"{pid}.*.key"):
|
|
313
|
+
try:
|
|
314
|
+
data = json.loads(key_file.read_text(encoding="utf-8"))
|
|
315
|
+
except (OSError, ValueError):
|
|
316
|
+
continue
|
|
317
|
+
auth = {"type": "auth"}
|
|
318
|
+
auth.update(data)
|
|
319
|
+
return json.dumps(auth, ensure_ascii=False)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _post_to_pipe(pipe_path: str, pid: int, text: str) -> None:
|
|
324
|
+
"""Write one message to a Windows named pipe inbox."""
|
|
325
|
+
check_message(text, remote=False)
|
|
326
|
+
|
|
327
|
+
auth_line = _read_win_auth(pid)
|
|
328
|
+
if auth_line is None:
|
|
329
|
+
raise CcPeerError(f"no auth key found for pid {pid} — cannot post to Windows pipe")
|
|
330
|
+
|
|
331
|
+
payload = json.dumps(
|
|
332
|
+
{"type": "user", "message": {"role": "user", "content": text}},
|
|
333
|
+
ensure_ascii=False,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
import time
|
|
337
|
+
try:
|
|
338
|
+
with open(pipe_path, "wb") as pipe:
|
|
339
|
+
pipe.write((auth_line + "\n").encode("utf-8"))
|
|
340
|
+
pipe.write((payload + "\n").encode("utf-8"))
|
|
341
|
+
pipe.flush()
|
|
342
|
+
time.sleep(DRAIN_TIMEOUT)
|
|
343
|
+
except OSError as exc:
|
|
344
|
+
raise CcPeerError(f"cannot reach inbox at {pipe_path}: {exc}") from exc
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def post_to_socket(socket_path: str, text: str, pid: int = 0) -> None:
|
|
348
|
+
"""Write one message to a session's inbox socket.
|
|
349
|
+
|
|
350
|
+
On macOS and Linux the {"type":"auth",...} line the docs describe is
|
|
351
|
+
optional, so this sends the message on its own. On Windows, auth is
|
|
352
|
+
mandatory — the token is read from the session's .key file and sent
|
|
353
|
+
before the message. Claude Code closes a connection that has not sent
|
|
354
|
+
a complete line within 30 seconds, so the message is built before the
|
|
355
|
+
socket is opened.
|
|
356
|
+
"""
|
|
357
|
+
if IS_WINDOWS and socket_path.startswith("\\\\.\\pipe\\"):
|
|
358
|
+
_post_to_pipe(socket_path, pid, text)
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
check_message(text, remote=False)
|
|
362
|
+
|
|
363
|
+
payload = json.dumps(
|
|
364
|
+
{"type": "user", "message": {"role": "user", "content": text}},
|
|
365
|
+
ensure_ascii=False,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
369
|
+
conn.settimeout(CONNECT_TIMEOUT)
|
|
370
|
+
try:
|
|
371
|
+
try:
|
|
372
|
+
conn.connect(socket_path)
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise CcPeerError(f"cannot reach inbox at {socket_path}: {exc}") from exc
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
conn.sendall((payload + "\n").encode("utf-8"))
|
|
378
|
+
conn.shutdown(socket.SHUT_WR)
|
|
379
|
+
conn.settimeout(DRAIN_TIMEOUT)
|
|
380
|
+
try:
|
|
381
|
+
conn.recv(1)
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
raise CcPeerError(f"failed writing to {socket_path}: {exc}") from exc
|
|
386
|
+
finally:
|
|
387
|
+
conn.close()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
# Reply address. A cross-machine message carries no reply address of its own,
|
|
392
|
+
# so the receiving Claude has no way to know answering is even possible. This
|
|
393
|
+
# appends one line saying where to send an answer.
|
|
394
|
+
#
|
|
395
|
+
# It grants nothing: the far side can only reply if it could already SSH here.
|
|
396
|
+
# What it adds is knowing that, which is what the receiver otherwise lacks.
|
|
397
|
+
# --------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def is_tailnet_address(candidate: str) -> bool:
|
|
401
|
+
parts = candidate.split(".")
|
|
402
|
+
if len(parts) != 4 or not all(p.isdigit() and len(p) <= 3 for p in parts):
|
|
403
|
+
return False
|
|
404
|
+
octets = [int(p) for p in parts]
|
|
405
|
+
if any(o > 255 for o in octets):
|
|
406
|
+
return False
|
|
407
|
+
return octets[0] == 100 and octets[1] in TAILNET_SECOND_OCTET
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _run(command: list[str]) -> str:
|
|
411
|
+
try:
|
|
412
|
+
done = subprocess.run(
|
|
413
|
+
command, capture_output=True, encoding="utf-8", errors="replace", timeout=DETECT_TIMEOUT
|
|
414
|
+
)
|
|
415
|
+
except (OSError, subprocess.SubprocessError):
|
|
416
|
+
return ""
|
|
417
|
+
return done.stdout
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def detect_reply_host() -> str | None:
|
|
421
|
+
"""This machine's tailnet address, or None if it can't be determined.
|
|
422
|
+
|
|
423
|
+
`tailscale ip -4` is the direct answer where the CLI is on PATH. On macOS it
|
|
424
|
+
usually isn't — the app ships it inside the bundle — so fall back to reading
|
|
425
|
+
it off the interfaces.
|
|
426
|
+
"""
|
|
427
|
+
for command in (["tailscale", "ip", "-4"], ["ip", "-4", "-o", "addr", "show"], ["ifconfig"]):
|
|
428
|
+
for candidate in re.findall(r"\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", _run(command)):
|
|
429
|
+
if is_tailnet_address(candidate):
|
|
430
|
+
return candidate
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def own_session() -> dict | None:
|
|
435
|
+
"""The session this process is running inside, if any.
|
|
436
|
+
|
|
437
|
+
Claude Code exports the session's own inbox socket path, which carries its
|
|
438
|
+
pid; the registry turns that into a name. Empty when run from a plain shell.
|
|
439
|
+
"""
|
|
440
|
+
socket_path = os.environ.get("CLAUDE_CODE_MESSAGING_SOCKET", "")
|
|
441
|
+
match = re.search(r"(\d+)\.sock$", socket_path)
|
|
442
|
+
if not match:
|
|
443
|
+
return None
|
|
444
|
+
pid = int(match.group(1))
|
|
445
|
+
for session in discover(include_unreachable=True):
|
|
446
|
+
if session["pid"] == pid:
|
|
447
|
+
return session
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def sender_identity(explicit_host: str | None) -> tuple[str, str | None] | None:
|
|
452
|
+
"""(session name, reachable address) for the session we're running in.
|
|
453
|
+
|
|
454
|
+
Both the From: header and the Reply: line are built from this, so they
|
|
455
|
+
can't drift apart. None outside a session, where there is no name to give.
|
|
456
|
+
"""
|
|
457
|
+
session = own_session()
|
|
458
|
+
if session is None:
|
|
459
|
+
return None
|
|
460
|
+
name = session["name"] or str(session["pid"])
|
|
461
|
+
host = explicit_host or (os.environ.get("SESSION_PEER_REPLY_HOST") or os.environ.get("CC_PEER_REPLY_HOST")) or detect_reply_host()
|
|
462
|
+
if host and "@" not in host:
|
|
463
|
+
host = f"{getpass.getuser()}@{host}"
|
|
464
|
+
return name, host
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def from_header(explicit_host: str | None) -> str | None:
|
|
468
|
+
"""Who is speaking.
|
|
469
|
+
|
|
470
|
+
Claude Code records an arriving peer message with `from: "unknown"` when
|
|
471
|
+
it was posted to the socket directly, so without this the receiver has no
|
|
472
|
+
idea who is asking — and it is told to treat the message as a teammate's
|
|
473
|
+
request. Separate from the reply address on purpose: knowing the sender
|
|
474
|
+
stays useful when answering isn't possible.
|
|
475
|
+
"""
|
|
476
|
+
identity = sender_identity(explicit_host)
|
|
477
|
+
if identity is None:
|
|
478
|
+
return None
|
|
479
|
+
name, host = identity
|
|
480
|
+
# Fall back to the local hostname so this line survives even when no
|
|
481
|
+
# tailnet address turns up; it is for reading, not for connecting.
|
|
482
|
+
where = host or f"{getpass.getuser()}@{socket.gethostname()}"
|
|
483
|
+
return f"From: {where} ({name})"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def wrap_message(
|
|
487
|
+
text: str, explicit_host: str | None, with_from: bool, with_reply: bool
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Put the body in an envelope: who sent it, and how to answer.
|
|
490
|
+
|
|
491
|
+
Deliberately minimal. Claude Code already prefaces an arriving peer
|
|
492
|
+
message and appends its own guidance about what a peer may and may not
|
|
493
|
+
ask for — repeating any of that here would duplicate it in every message
|
|
494
|
+
and compound with each hop. The two facts it *doesn't* have are the
|
|
495
|
+
sender's identity and a working return address.
|
|
496
|
+
"""
|
|
497
|
+
header = from_header(explicit_host) if with_from else None
|
|
498
|
+
footer = reply_line(explicit_host) if with_reply else None
|
|
499
|
+
body = text.strip("\n")
|
|
500
|
+
parts = ([header, ""] if header else []) + [body]
|
|
501
|
+
if footer:
|
|
502
|
+
parts += ["", "---", footer]
|
|
503
|
+
return "\n".join(parts)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def reply_line(explicit_host: str | None) -> str | None:
|
|
507
|
+
"""How to answer, as a command that runs verbatim.
|
|
508
|
+
|
|
509
|
+
Three things the first version left out, each of which broke it in
|
|
510
|
+
practice:
|
|
511
|
+
|
|
512
|
+
* the **user**, because the receiver otherwise connects as its own local
|
|
513
|
+
account — a worker running as `ubuntu` cannot reach a laptop's `abruptly`
|
|
514
|
+
* an **absolute script path**, because a non-interactive SSH session never
|
|
515
|
+
sources the profile that puts ~/.local/bin on PATH, so a bare `session-peer`
|
|
516
|
+
is not found
|
|
517
|
+
* `--no-reply-to`, so answering an answer doesn't ping-pong
|
|
518
|
+
|
|
519
|
+
The username is the sender's; there's no guarantee the far side knows it,
|
|
520
|
+
but it is right whenever accounts match and strictly better than nothing.
|
|
521
|
+
"""
|
|
522
|
+
identity = sender_identity(explicit_host)
|
|
523
|
+
if identity is None:
|
|
524
|
+
return None
|
|
525
|
+
name, host = identity
|
|
526
|
+
if not host:
|
|
527
|
+
return None
|
|
528
|
+
script = Path(__file__).resolve()
|
|
529
|
+
script_str = str(script) if script.is_file() else '~/.local/share/session-peer/session_peer.py'
|
|
530
|
+
return (
|
|
531
|
+
f"Reply: python3 {script_str} send "
|
|
532
|
+
f"--host {host} --to {shlex.quote(name)} --no-reply-to"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --------------------------------------------------------------------------
|
|
537
|
+
# Remote dispatch. Ships this file over SSH and runs it there, so the remote
|
|
538
|
+
# machine needs nothing installed beyond python3.
|
|
539
|
+
# --------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ssh options that make ssh run a command on *this* machine. A host or an
|
|
543
|
+
# --ssh-opt value carrying one of these turns "message a session" into "run
|
|
544
|
+
# whatever I say, locally". ProxyJump is deliberately absent: it takes a host,
|
|
545
|
+
# not a command, and is the right way to reach a box behind a bastion.
|
|
546
|
+
LOCAL_EXEC_SSH_OPTIONS = ("proxycommand", "localcommand", "permitlocalcommand")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def check_ssh_argument(value: str, flag: str) -> None:
|
|
550
|
+
"""Refuse a value that would make ssh do something other than connect.
|
|
551
|
+
|
|
552
|
+
ssh has no `--` separator, so a leading dash turns a destination into a
|
|
553
|
+
flag. Hosts never legitimately start with one, while --ssh-opt values
|
|
554
|
+
always do — so the leading-dash rule applies only to the host, and both
|
|
555
|
+
are checked for options that execute a local command.
|
|
556
|
+
"""
|
|
557
|
+
if flag == "--host" and value.startswith("-"):
|
|
558
|
+
raise CcPeerError(
|
|
559
|
+
f"--host must not start with '-' (ssh would read {value!r} as an option)"
|
|
560
|
+
)
|
|
561
|
+
collapsed = value.lower().replace(" ", "").replace("=", "")
|
|
562
|
+
for banned in LOCAL_EXEC_SSH_OPTIONS:
|
|
563
|
+
if banned in collapsed:
|
|
564
|
+
raise CcPeerError(
|
|
565
|
+
f"{flag} must not carry {banned} — it would run a command on this "
|
|
566
|
+
f"machine. Put it in ~/.ssh/config if you really need it."
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict:
|
|
571
|
+
check_ssh_argument(host, "--host")
|
|
572
|
+
for opt in ssh_opts:
|
|
573
|
+
check_ssh_argument(opt, "--ssh-opt")
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
source = Path(__file__).resolve().read_text(encoding="utf-8")
|
|
577
|
+
except OSError as exc: # pragma: no cover - only when run from a pipe
|
|
578
|
+
raise CcPeerError(f"cannot read own source to send to {host}: {exc}") from exc
|
|
579
|
+
|
|
580
|
+
# ssh joins everything after the destination with spaces and hands the
|
|
581
|
+
# result to the remote *shell*, so an argv list is not the protection it
|
|
582
|
+
# looks like: a metacharacter in any element executes over there. Build
|
|
583
|
+
# the remote command as one already-quoted string instead.
|
|
584
|
+
remote = " ".join(shlex.quote(a) for a in ["python3", "-", *argv, "--json"])
|
|
585
|
+
command = ["ssh", *ssh_opts, host, remote]
|
|
586
|
+
try:
|
|
587
|
+
completed = subprocess.run(
|
|
588
|
+
command, input=source, encoding="utf-8", capture_output=True, timeout=120
|
|
589
|
+
)
|
|
590
|
+
except FileNotFoundError as exc:
|
|
591
|
+
raise CcPeerError("ssh not found on PATH") from exc
|
|
592
|
+
except subprocess.TimeoutExpired as exc:
|
|
593
|
+
raise CcPeerError(f"ssh to {host} timed out") from exc
|
|
594
|
+
except OSError as exc:
|
|
595
|
+
raise CcPeerError(f"could not run ssh to {host}: {exc}") from exc
|
|
596
|
+
|
|
597
|
+
stdout = completed.stdout.strip()
|
|
598
|
+
if not stdout:
|
|
599
|
+
detail = completed.stderr.strip() or f"ssh exited {completed.returncode}"
|
|
600
|
+
raise CcPeerError(f"{host}: {detail}")
|
|
601
|
+
try:
|
|
602
|
+
result = json.loads(stdout)
|
|
603
|
+
except ValueError as exc:
|
|
604
|
+
raise CcPeerError(f"{host}: unexpected output: {stdout[:200]}") from exc
|
|
605
|
+
|
|
606
|
+
# The far end reports its own failures in-band; surface them here rather
|
|
607
|
+
# than letting a caller read an error payload as a success.
|
|
608
|
+
if isinstance(result, dict) and result.get("ok") is False:
|
|
609
|
+
raise CcPeerError(f"{host}: {result.get('error', 'remote command failed')}")
|
|
610
|
+
return result
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def push_to_remote(host: str, ssh_opts: list[str]) -> str:
|
|
614
|
+
"""Push this script to a remote machine's skill dir over SSH.
|
|
615
|
+
|
|
616
|
+
Returns the version string reported by the newly installed copy.
|
|
617
|
+
The remote machine needs only python3 and ssh access — no internet,
|
|
618
|
+
no install.sh.
|
|
619
|
+
"""
|
|
620
|
+
check_ssh_argument(host, "--host")
|
|
621
|
+
for opt in ssh_opts:
|
|
622
|
+
check_ssh_argument(opt, "--ssh-opt")
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
source = Path(__file__).resolve().read_bytes()
|
|
626
|
+
except OSError as exc:
|
|
627
|
+
raise CcPeerError(f"cannot read own source to push to {host}: {exc}") from exc
|
|
628
|
+
|
|
629
|
+
source_b64 = base64.b64encode(source).decode("ascii")
|
|
630
|
+
|
|
631
|
+
remote_script = (
|
|
632
|
+
"set -eu; "
|
|
633
|
+
'D="$HOME/.local/share/session-peer"; '
|
|
634
|
+
'mkdir -p "$D" "$HOME/.local/bin"; '
|
|
635
|
+
'base64 -d > "$D/session_peer.py"; '
|
|
636
|
+
'chmod +x "$D/session_peer.py"; '
|
|
637
|
+
'ln -sf "$D/session_peer.py" "$HOME/.local/bin/session-peer"; '
|
|
638
|
+
'python3 "$D/session_peer.py" --version 2>/dev/null || echo "session-peer unknown"'
|
|
639
|
+
)
|
|
640
|
+
command = ["ssh", *ssh_opts, host, remote_script]
|
|
641
|
+
try:
|
|
642
|
+
completed = subprocess.run(
|
|
643
|
+
command, input=source_b64, encoding="utf-8",
|
|
644
|
+
capture_output=True, timeout=120,
|
|
645
|
+
)
|
|
646
|
+
except FileNotFoundError as exc:
|
|
647
|
+
raise CcPeerError("ssh not found on PATH") from exc
|
|
648
|
+
except subprocess.TimeoutExpired as exc:
|
|
649
|
+
raise CcPeerError(f"ssh to {host} timed out") from exc
|
|
650
|
+
except OSError as exc:
|
|
651
|
+
raise CcPeerError(f"could not run ssh to {host}: {exc}") from exc
|
|
652
|
+
|
|
653
|
+
if completed.returncode != 0:
|
|
654
|
+
detail = completed.stderr.strip() or f"ssh exited {completed.returncode}"
|
|
655
|
+
raise CcPeerError(f"{host}: {detail}")
|
|
656
|
+
|
|
657
|
+
version_line = completed.stdout.strip()
|
|
658
|
+
parts = version_line.split()
|
|
659
|
+
return parts[-1] if parts else "unknown"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# --------------------------------------------------------------------------
|
|
663
|
+
# Output
|
|
664
|
+
# --------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def render_sessions(sessions: list[dict], where: str) -> str:
|
|
668
|
+
if not sessions:
|
|
669
|
+
return f"No reachable Claude Code sessions on {where}."
|
|
670
|
+
|
|
671
|
+
rows = [("NAME", "PID", "STATUS", "CWD")]
|
|
672
|
+
for s in sessions:
|
|
673
|
+
name = s["name"] or "(unnamed)"
|
|
674
|
+
if not s["reachable"]:
|
|
675
|
+
name += " [no inbox]" if s["alive"] else " [stale record]"
|
|
676
|
+
rows.append((name, str(s["pid"]), s["status"] or "-", s["cwd"] or "-"))
|
|
677
|
+
|
|
678
|
+
widths = [max(len(row[i]) for row in rows) for i in range(3)]
|
|
679
|
+
lines = [
|
|
680
|
+
f"{r[0]:<{widths[0]}} {r[1]:<{widths[1]}} {r[2]:<{widths[2]}} {r[3]}"
|
|
681
|
+
for r in rows
|
|
682
|
+
]
|
|
683
|
+
return f"Sessions on {where}:\n" + "\n".join(lines)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def emit(as_json: bool, payload: dict, human: str) -> None:
|
|
687
|
+
print(json.dumps(payload, ensure_ascii=False) if as_json else human)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# --------------------------------------------------------------------------
|
|
691
|
+
# Commands
|
|
692
|
+
# --------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
696
|
+
is_codex = getattr(args, "agent", "claude") == "codex"
|
|
697
|
+
render = render_codex if is_codex else render_sessions
|
|
698
|
+
if not args.host:
|
|
699
|
+
sessions = discover_codex(args) if is_codex else discover(include_unreachable=args.all)
|
|
700
|
+
human = render(sessions, "this machine")
|
|
701
|
+
emit(args.json, {"sessions": sessions, "version": __version__}, human)
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
exit_code = 0
|
|
705
|
+
all_results = []
|
|
706
|
+
for host in args.host:
|
|
707
|
+
try:
|
|
708
|
+
argv = ["list"] + (["--all"] if args.all else [])
|
|
709
|
+
if is_codex:
|
|
710
|
+
argv += ["--agent", "codex"] + codex_remote_options(args)
|
|
711
|
+
result = run_remote(host, argv, args.ssh_opt)
|
|
712
|
+
sessions = result.get("sessions", [])
|
|
713
|
+
remote_version = remote_installed_version(host, args.ssh_opt)
|
|
714
|
+
human = render(sessions, host)
|
|
715
|
+
if remote_version and remote_version != __version__:
|
|
716
|
+
human = (
|
|
717
|
+
f"{host} runs session-peer {remote_version}; this machine has {__version__}."
|
|
718
|
+
f"\nUpdate it with: session-peer update --host {host}\n\n{human}"
|
|
719
|
+
)
|
|
720
|
+
host_result = {
|
|
721
|
+
"host": host, "sessions": sessions, "version": __version__,
|
|
722
|
+
**({"remoteVersion": remote_version} if remote_version else {}),
|
|
723
|
+
}
|
|
724
|
+
all_results.append(host_result)
|
|
725
|
+
if not args.json:
|
|
726
|
+
if len(all_results) > 1:
|
|
727
|
+
print()
|
|
728
|
+
print(human)
|
|
729
|
+
except CcPeerError as exc:
|
|
730
|
+
exit_code = EXIT_ERROR
|
|
731
|
+
all_results.append({"host": host, "ok": False, "error": str(exc)})
|
|
732
|
+
if not args.json:
|
|
733
|
+
print(f"session-peer: {host}: {exc}", file=sys.stderr)
|
|
734
|
+
|
|
735
|
+
if args.json:
|
|
736
|
+
print(json.dumps(all_results, ensure_ascii=False))
|
|
737
|
+
return exit_code
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def read_message(args: argparse.Namespace) -> str:
|
|
741
|
+
if args.b64 is not None:
|
|
742
|
+
try:
|
|
743
|
+
return base64.b64decode(args.b64, validate=True).decode("utf-8")
|
|
744
|
+
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
745
|
+
raise CcPeerError(f"--b64 is not valid base64-encoded UTF-8: {exc}") from exc
|
|
746
|
+
if args.message is None or args.message == "-":
|
|
747
|
+
if sys.stdin.isatty():
|
|
748
|
+
raise CcPeerError(
|
|
749
|
+
"no message given and stdin is a terminal — "
|
|
750
|
+
"pass a message argument or pipe one in"
|
|
751
|
+
)
|
|
752
|
+
try:
|
|
753
|
+
return sys.stdin.read()
|
|
754
|
+
except UnicodeDecodeError as exc:
|
|
755
|
+
raise CcPeerError(f"stdin is not valid UTF-8: {exc}") from exc
|
|
756
|
+
return args.message
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def remote_installed_version(host: str, ssh_opts: list[str]) -> str | None:
|
|
760
|
+
"""Version of the copy *installed* on that machine.
|
|
761
|
+
|
|
762
|
+
Not the same thing as asking the remote command to report itself:
|
|
763
|
+
run_remote() ships our own source and runs that, so it would always echo
|
|
764
|
+
our version back. The installed file is what a session over there will
|
|
765
|
+
actually use, and it is what can fall behind.
|
|
766
|
+
"""
|
|
767
|
+
check_ssh_argument(host, "--host")
|
|
768
|
+
for opt in ssh_opts:
|
|
769
|
+
check_ssh_argument(opt, "--ssh-opt")
|
|
770
|
+
probe = 'python3 "$HOME/.local/share/session-peer/session_peer.py" --version 2>/dev/null'
|
|
771
|
+
try:
|
|
772
|
+
done = subprocess.run(
|
|
773
|
+
["ssh", *ssh_opts, host, probe],
|
|
774
|
+
capture_output=True, encoding="utf-8", errors="replace", timeout=30,
|
|
775
|
+
)
|
|
776
|
+
except (OSError, subprocess.SubprocessError):
|
|
777
|
+
return None
|
|
778
|
+
out = done.stdout.strip()
|
|
779
|
+
return out.split()[-1] if out.startswith("session-peer") else None
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def parse_version(text: str) -> tuple[int, ...]:
|
|
783
|
+
"""(1, 2, 3) from "v1.2.3". Unparseable parts sort lowest."""
|
|
784
|
+
parts = text.strip().lstrip("vV").split(".")
|
|
785
|
+
return tuple(int(p) if p.isdigit() else 0 for p in parts[:3])
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def latest_release() -> tuple[str, str]:
|
|
789
|
+
"""(tag, download URL) of the newest release on GitHub."""
|
|
790
|
+
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
|
791
|
+
request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
|
|
792
|
+
try:
|
|
793
|
+
with urllib.request.urlopen(request, timeout=DETECT_TIMEOUT * 4) as response:
|
|
794
|
+
tag = json.load(response).get("tag_name", "")
|
|
795
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
796
|
+
raise CcPeerError(
|
|
797
|
+
f"could not reach GitHub to check for updates: {exc}. "
|
|
798
|
+
f"On a host with no route out, update it from a machine that has one: "
|
|
799
|
+
f"session-peer update --host <this host>"
|
|
800
|
+
) from exc
|
|
801
|
+
if not tag:
|
|
802
|
+
raise CcPeerError("GitHub returned no release tag")
|
|
803
|
+
return tag, f"https://raw.githubusercontent.com/{GITHUB_REPO}/{tag}/session_peer.py"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def installed_as_distribution() -> bool:
|
|
807
|
+
"""Do not overwrite a file owned by pip/pipx/uv with the script updater."""
|
|
808
|
+
try:
|
|
809
|
+
dist = metadata.distribution("session-peer")
|
|
810
|
+
except metadata.PackageNotFoundError:
|
|
811
|
+
return False
|
|
812
|
+
target = Path(__file__).resolve()
|
|
813
|
+
return any(Path(dist.locate_file(f)).resolve() == target for f in (dist.files or []))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
817
|
+
if not args.host and installed_as_distribution():
|
|
818
|
+
emit(
|
|
819
|
+
args.json,
|
|
820
|
+
{"current": __version__, "updated": False,
|
|
821
|
+
"managedBy": "package-manager"},
|
|
822
|
+
"This installation is package-managed. Upgrade with its installer: "
|
|
823
|
+
"pipx upgrade session-peer, uv tool upgrade session-peer, "
|
|
824
|
+
"or python -m pip install --upgrade session-peer.",
|
|
825
|
+
)
|
|
826
|
+
return 0
|
|
827
|
+
if args.host:
|
|
828
|
+
exit_code = 0
|
|
829
|
+
all_results = []
|
|
830
|
+
for host in args.host:
|
|
831
|
+
try:
|
|
832
|
+
there = remote_installed_version(host, args.ssh_opt)
|
|
833
|
+
|
|
834
|
+
if args.check:
|
|
835
|
+
if there is None:
|
|
836
|
+
state = "not installed"
|
|
837
|
+
elif there == __version__:
|
|
838
|
+
state = "up to date"
|
|
839
|
+
else:
|
|
840
|
+
state = f"{there} → {__version__} available"
|
|
841
|
+
all_results.append({"host": host, "remoteVersion": there,
|
|
842
|
+
"current": __version__, "outdated": there != __version__})
|
|
843
|
+
if not args.json:
|
|
844
|
+
print(f"{host}: session-peer {there or '(none)'} — {state}")
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
if there == __version__:
|
|
848
|
+
all_results.append({"host": host, "remoteVersion": there,
|
|
849
|
+
"current": __version__, "updated": False})
|
|
850
|
+
if not args.json:
|
|
851
|
+
print(f"{host} runs session-peer {there} — already current.")
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
new_version = push_to_remote(host, args.ssh_opt)
|
|
855
|
+
all_results.append({"host": host, "ok": True, "previous": there,
|
|
856
|
+
"current": __version__, "updated": True})
|
|
857
|
+
if not args.json:
|
|
858
|
+
prev = there or "(none)"
|
|
859
|
+
print(f"{host}: session-peer {prev} → {new_version}")
|
|
860
|
+
|
|
861
|
+
except CcPeerError as exc:
|
|
862
|
+
exit_code = EXIT_ERROR
|
|
863
|
+
all_results.append({"host": host, "ok": False, "error": str(exc)})
|
|
864
|
+
if not args.json:
|
|
865
|
+
print(f"session-peer: {host}: {exc}", file=sys.stderr)
|
|
866
|
+
if args.json:
|
|
867
|
+
print(json.dumps(all_results, ensure_ascii=False))
|
|
868
|
+
return exit_code
|
|
869
|
+
|
|
870
|
+
tag, url = latest_release()
|
|
871
|
+
latest, current = parse_version(tag), parse_version(__version__)
|
|
872
|
+
|
|
873
|
+
if args.check:
|
|
874
|
+
state = "up to date" if current >= latest else f"{tag} available"
|
|
875
|
+
emit(
|
|
876
|
+
args.json,
|
|
877
|
+
{"current": __version__, "latest": tag, "outdated": current < latest},
|
|
878
|
+
f"session-peer {__version__} — {state}",
|
|
879
|
+
)
|
|
880
|
+
return 0
|
|
881
|
+
|
|
882
|
+
if current >= latest:
|
|
883
|
+
emit(args.json, {"current": __version__, "latest": tag, "updated": False},
|
|
884
|
+
f"session-peer {__version__} is already current ({tag}).")
|
|
885
|
+
return 0
|
|
886
|
+
|
|
887
|
+
target = Path(__file__).resolve()
|
|
888
|
+
try:
|
|
889
|
+
with urllib.request.urlopen(url, timeout=DETECT_TIMEOUT * 4) as response:
|
|
890
|
+
source = response.read()
|
|
891
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
892
|
+
raise CcPeerError(f"could not download {tag}: {exc}") from exc
|
|
893
|
+
if b"__version__" not in source:
|
|
894
|
+
raise CcPeerError(f"what came back from {url} does not look like session_peer.py")
|
|
895
|
+
|
|
896
|
+
# We are running from the file being replaced. Write beside it and rename,
|
|
897
|
+
# so a failed download can't leave a half-written script behind.
|
|
898
|
+
staged = target.with_suffix(".py.new")
|
|
899
|
+
try:
|
|
900
|
+
staged.write_bytes(source)
|
|
901
|
+
staged.chmod(target.stat().st_mode & 0o777)
|
|
902
|
+
staged.replace(target)
|
|
903
|
+
except OSError as exc:
|
|
904
|
+
staged.unlink(missing_ok=True)
|
|
905
|
+
raise CcPeerError(f"could not replace {target}: {exc}") from exc
|
|
906
|
+
|
|
907
|
+
emit(
|
|
908
|
+
args.json,
|
|
909
|
+
{"current": __version__, "latest": tag, "updated": True, "path": str(target)},
|
|
910
|
+
f"session-peer {__version__} → {tag} ({target})",
|
|
911
|
+
)
|
|
912
|
+
return 0
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def cmd_send(args: argparse.Namespace) -> int:
|
|
916
|
+
text = read_message(args)
|
|
917
|
+
is_codex = args.to.startswith("codex:")
|
|
918
|
+
if is_codex:
|
|
919
|
+
codex_thread(args.to)
|
|
920
|
+
|
|
921
|
+
# Check the body the user actually wrote. Doing this after the reply line
|
|
922
|
+
# is appended would let an empty message through on the strength of the
|
|
923
|
+
# line alone — which still starts a turn on the other machine.
|
|
924
|
+
check_message(text, remote=bool(args.host))
|
|
925
|
+
|
|
926
|
+
# Built here, before dispatch: detection has to run on the sender's machine.
|
|
927
|
+
# Doing it on the far side would advertise the receiver's own address back
|
|
928
|
+
# at it. The --b64 path is this script re-running remotely, where the
|
|
929
|
+
# envelope is already part of the payload.
|
|
930
|
+
if args.b64 is None:
|
|
931
|
+
text = wrap_message(
|
|
932
|
+
text,
|
|
933
|
+
explicit_host=args.reply_to,
|
|
934
|
+
with_from=not args.no_from,
|
|
935
|
+
with_reply=not args.no_reply_to,
|
|
936
|
+
)
|
|
937
|
+
if (
|
|
938
|
+
not args.no_reply_to
|
|
939
|
+
and reply_line(args.reply_to) is None
|
|
940
|
+
and (args.reply_to or (os.environ.get("SESSION_PEER_REPLY_HOST") or os.environ.get("CC_PEER_REPLY_HOST")))
|
|
941
|
+
and not args.json
|
|
942
|
+
):
|
|
943
|
+
# A reply address names a session, and outside one there is no name
|
|
944
|
+
# to give. Say so rather than dropping the flag without a word.
|
|
945
|
+
print(
|
|
946
|
+
"session-peer: no reply address sent — a reply needs a session to name, "
|
|
947
|
+
"and this isn't running inside one",
|
|
948
|
+
file=sys.stderr,
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
if is_codex:
|
|
952
|
+
check_codex_message(text)
|
|
953
|
+
|
|
954
|
+
if not args.host and is_codex:
|
|
955
|
+
result = queue_codex(args, text)
|
|
956
|
+
emit(args.json, result, codex_submission_text(result, "this machine"))
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
if not args.host:
|
|
960
|
+
session = resolve_target(discover(include_unreachable=True), args.to)
|
|
961
|
+
if not args.dry_run:
|
|
962
|
+
post_to_socket(session["socket"], text, pid=session["pid"])
|
|
963
|
+
target = {"pid": session["pid"], "name": session["name"]}
|
|
964
|
+
verb = "Would post to" if args.dry_run else "Posted to"
|
|
965
|
+
name = target.get("name") or target.get("pid")
|
|
966
|
+
emit(
|
|
967
|
+
args.json,
|
|
968
|
+
{"ok": True, "target": target, "chars": len(text), "dryRun": args.dry_run},
|
|
969
|
+
f"{verb} {name}'s inbox on this machine ({len(text)} chars).",
|
|
970
|
+
)
|
|
971
|
+
return 0
|
|
972
|
+
|
|
973
|
+
exit_code = 0
|
|
974
|
+
all_results = []
|
|
975
|
+
for host in args.host:
|
|
976
|
+
try:
|
|
977
|
+
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
978
|
+
remote_argv = ["send", "--to", args.to, "--b64", encoded]
|
|
979
|
+
if is_codex:
|
|
980
|
+
remote_argv += codex_remote_options(args)
|
|
981
|
+
if args.dry_run:
|
|
982
|
+
remote_argv.append("--dry-run")
|
|
983
|
+
result = run_remote(host, remote_argv, args.ssh_opt)
|
|
984
|
+
if is_codex:
|
|
985
|
+
result["host"] = host
|
|
986
|
+
all_results.append(result)
|
|
987
|
+
if not args.json:
|
|
988
|
+
print(codex_submission_text(result, host))
|
|
989
|
+
continue
|
|
990
|
+
target = result.get("target", {})
|
|
991
|
+
name = target.get("name") or target.get("pid")
|
|
992
|
+
verb = "Would post to" if args.dry_run else "Posted to"
|
|
993
|
+
host_result = {"ok": True, "host": host, "target": target,
|
|
994
|
+
"chars": len(text), "dryRun": args.dry_run}
|
|
995
|
+
all_results.append(host_result)
|
|
996
|
+
if not args.json:
|
|
997
|
+
print(f"{verb} {name}'s inbox on {host} ({len(text)} chars).")
|
|
998
|
+
except CcPeerError as exc:
|
|
999
|
+
exit_code = EXIT_ERROR
|
|
1000
|
+
all_results.append({"ok": False, "host": host, "error": str(exc)})
|
|
1001
|
+
if not args.json:
|
|
1002
|
+
print(f"session-peer: {host}: {exc}", file=sys.stderr)
|
|
1003
|
+
|
|
1004
|
+
if args.json:
|
|
1005
|
+
if len(all_results) == 1:
|
|
1006
|
+
print(json.dumps(all_results[0], ensure_ascii=False))
|
|
1007
|
+
else:
|
|
1008
|
+
print(json.dumps(all_results, ensure_ascii=False))
|
|
1009
|
+
return exit_code
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1013
|
+
parser = argparse.ArgumentParser(
|
|
1014
|
+
prog="session-peer",
|
|
1015
|
+
description="Message Claude Code and Codex sessions locally or over SSH.",
|
|
1016
|
+
)
|
|
1017
|
+
parser.add_argument("--version", action="version", version=f"session-peer {__version__}")
|
|
1018
|
+
|
|
1019
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1020
|
+
|
|
1021
|
+
def add_common(sub: argparse.ArgumentParser) -> None:
|
|
1022
|
+
sub.add_argument(
|
|
1023
|
+
"--host", action="append", default=[], metavar="DEST",
|
|
1024
|
+
help="SSH destination, repeatable; omit to act on this machine",
|
|
1025
|
+
)
|
|
1026
|
+
sub.add_argument(
|
|
1027
|
+
"--ssh-opt",
|
|
1028
|
+
action="append",
|
|
1029
|
+
default=[],
|
|
1030
|
+
metavar="OPT",
|
|
1031
|
+
help="extra ssh argument, repeatable (e.g. --ssh-opt -p --ssh-opt 2222)",
|
|
1032
|
+
)
|
|
1033
|
+
sub.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1034
|
+
|
|
1035
|
+
listing = subparsers.add_parser("list", help="list sessions that can be messaged")
|
|
1036
|
+
add_common(listing)
|
|
1037
|
+
listing.add_argument("--agent", choices=("claude", "codex"), default="claude")
|
|
1038
|
+
listing.add_argument("--codex-home", help="Codex home on the destination machine")
|
|
1039
|
+
listing.add_argument("--codex-bin", help="Codex executable on the destination (used by send)")
|
|
1040
|
+
listing.add_argument(
|
|
1041
|
+
"--all", action="store_true", help="include stale records and sessions with no inbox"
|
|
1042
|
+
)
|
|
1043
|
+
listing.set_defaults(func=cmd_list)
|
|
1044
|
+
|
|
1045
|
+
sending = subparsers.add_parser("send", help="send one message to a session")
|
|
1046
|
+
add_common(sending)
|
|
1047
|
+
sending.add_argument("--to", required=True, metavar="NAME|PID|codex:UUID", help="target session")
|
|
1048
|
+
sending.add_argument("--codex-home", help="Codex home on the destination machine")
|
|
1049
|
+
sending.add_argument("--codex-bin", help="Codex executable on the destination machine")
|
|
1050
|
+
sending.add_argument("message", nargs="?", help="message text; omit or use - to read stdin")
|
|
1051
|
+
sending.add_argument("--b64", help=argparse.SUPPRESS) # used for remote dispatch
|
|
1052
|
+
sending.add_argument(
|
|
1053
|
+
"--reply-to",
|
|
1054
|
+
metavar="HOST",
|
|
1055
|
+
help="reply address to advertise (default: this machine's tailnet address)",
|
|
1056
|
+
)
|
|
1057
|
+
sending.add_argument(
|
|
1058
|
+
"--no-reply-to", action="store_true", help="send without a reply address"
|
|
1059
|
+
)
|
|
1060
|
+
sending.add_argument(
|
|
1061
|
+
"--no-from", action="store_true", help="send without the From: header"
|
|
1062
|
+
)
|
|
1063
|
+
sending.add_argument("--dry-run", action="store_true", help="resolve the target, send nothing")
|
|
1064
|
+
sending.set_defaults(func=cmd_send)
|
|
1065
|
+
|
|
1066
|
+
updating = subparsers.add_parser("update", help="update this installation")
|
|
1067
|
+
add_common(updating)
|
|
1068
|
+
updating.add_argument(
|
|
1069
|
+
"--check", action="store_true", help="report the available version, change nothing"
|
|
1070
|
+
)
|
|
1071
|
+
updating.set_defaults(func=cmd_update)
|
|
1072
|
+
|
|
1073
|
+
return parser
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1077
|
+
if IS_WINDOWS:
|
|
1078
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1079
|
+
if hasattr(stream, "reconfigure"):
|
|
1080
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
1081
|
+
|
|
1082
|
+
args = build_parser().parse_args(argv)
|
|
1083
|
+
try:
|
|
1084
|
+
return args.func(args)
|
|
1085
|
+
except CcPeerError as exc:
|
|
1086
|
+
message = str(exc)
|
|
1087
|
+
if args.json:
|
|
1088
|
+
print(json.dumps({"ok": False, "error": message}, ensure_ascii=False))
|
|
1089
|
+
else:
|
|
1090
|
+
print(f"session-peer: {message}", file=sys.stderr)
|
|
1091
|
+
return EXIT_NO_TARGET if isinstance(exc, NoTargetError) or "no reachable session" in message else EXIT_ERROR
|
|
1092
|
+
except KeyboardInterrupt:
|
|
1093
|
+
return 130
|
|
1094
|
+
except Exception as exc:
|
|
1095
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
1096
|
+
if getattr(args, "json", False):
|
|
1097
|
+
print(json.dumps({"ok": False, "error": message}, ensure_ascii=False))
|
|
1098
|
+
else:
|
|
1099
|
+
print(f"session-peer: {message}", file=sys.stderr)
|
|
1100
|
+
return EXIT_ERROR
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
if __name__ == "__main__":
|
|
1104
|
+
sys.exit(main())
|