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())