cc-session-control 0.4.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.
@@ -0,0 +1,214 @@
1
+ """The only module that touches `/proc` — Linux/WSL liveness primitives.
2
+
3
+ Everything here degrades safely off Linux (no `/proc`): `proc_starttime`
4
+ returns None, `pid_alive` returns False, and `ancestor_pids` returns just this
5
+ process. Callers use `has_proc()` to detect the degraded mode and (in later
6
+ phases) refuse destructive ops when the "current" session can't be determined.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shlex
13
+ from dataclasses import dataclass
14
+
15
+ _PROC = "/proc"
16
+
17
+
18
+ @dataclass
19
+ class ProcRC:
20
+ """A /proc-discovered Claude project RC server (`claude remote-control`).
21
+
22
+ Internal to this module — the public, view-facing model is `RCServer`
23
+ (assembled in `data/rc.py` after classifying managed vs external). `pid` is
24
+ 0 when produced by the pure matcher (the scanner fills it); `cwd` comes from
25
+ `readlink(/proc/<pid>/cwd)`.
26
+ """
27
+ pid: int
28
+ name: str = ""
29
+ cwd: str = ""
30
+
31
+
32
+ def has_proc() -> bool:
33
+ """True if `/proc` is readable (Linux/WSL). Liveness degrades when False."""
34
+ return os.path.isdir(_PROC)
35
+
36
+
37
+ def current_determinable() -> bool:
38
+ """Whether the "current" (csctl-launching) session can be determined.
39
+
40
+ Needs `/proc` to walk the ancestor pid chain. When False (e.g. macOS), we
41
+ cannot tell which session launched csctl, so callers MUST refuse destructive
42
+ ops — terminate/delete/cleanup could otherwise hit the launching session
43
+ (R10). This is the single predicate the data/action layers gate on.
44
+ """
45
+ return has_proc()
46
+
47
+
48
+ def proc_starttime(pid: int) -> str | None:
49
+ """Field 22 (starttime) from `/proc/<pid>/stat`, or None if unavailable.
50
+
51
+ The comm field (field 2) is wrapped in parens and may itself contain spaces
52
+ or parens, so we slice AFTER the last ')' before splitting — a naive
53
+ `split()[21]` would break on such names. Returns the raw string so it can be
54
+ compared directly against the `procStart` string in `sessions/<pid>.json`.
55
+ """
56
+ if not has_proc():
57
+ return None
58
+ try:
59
+ with open(f"{_PROC}/{pid}/stat") as fh:
60
+ data = fh.read()
61
+ except Exception:
62
+ return None
63
+ try:
64
+ # `after` begins at field 3 (state); field 22 is at index 22 - 3.
65
+ after = data[data.rfind(")") + 2:]
66
+ return after.split()[22 - 3]
67
+ except Exception:
68
+ return None
69
+
70
+
71
+ def pid_alive(pid: int | None, proc_start: str | None) -> bool:
72
+ """True iff `/proc/<pid>` exists AND its starttime matches `proc_start`.
73
+
74
+ The starttime match defeats pid reuse (a recycled pid has a newer
75
+ starttime). When `proc_start` is unknown we fall back to mere existence.
76
+ Always False on non-Linux / missing `/proc`, so liveness degrades.
77
+ """
78
+ if not pid:
79
+ return False
80
+ st = proc_starttime(pid)
81
+ if st is None:
82
+ return False
83
+ if not proc_start:
84
+ return True
85
+ return st == proc_start
86
+
87
+
88
+ def ancestor_pids() -> set[int]:
89
+ """csctl's own ancestor pid chain (including self).
90
+
91
+ A session whose pid is in this set is the "current" one (it launched
92
+ csctl) and is protected. Linux/WSL only — returns just `{getpid()}` when
93
+ `/proc` is unavailable, in which case current can't be determined and
94
+ callers must degrade (see R10).
95
+ """
96
+ pids = {os.getpid()}
97
+ if not has_proc():
98
+ return pids
99
+ pid = os.getpid()
100
+ for _ in range(40):
101
+ try:
102
+ with open(f"{_PROC}/{pid}/stat") as fh:
103
+ data = fh.read()
104
+ ppid = int(data[data.rfind(")") + 2:].split()[1])
105
+ except Exception:
106
+ break
107
+ if ppid <= 1:
108
+ break
109
+ pids.add(ppid)
110
+ pid = ppid
111
+ return pids
112
+
113
+
114
+ # --- project RC server discovery (R5 / D5) ---------------------------------
115
+ # A real `claude remote-control --name <name>` server's /proc cmdline shows the
116
+ # FULL argv (verified live: a bare interactive `claude` instead collapses its
117
+ # cmdline to just `claude`), so we match on the argv SHAPE, not on `comm` (a
118
+ # node-launched claude can have comm `node`). Other tools are excluded — codex
119
+ # runs `--remote-control` as a FLAG with argv0 `codex` and no `remote-control`
120
+ # subcommand token, so it never matches.
121
+
122
+
123
+ def _split_cmdline(cmdline: str) -> list[str]:
124
+ """Split a `/proc/<pid>/cmdline` string into argv.
125
+
126
+ Real cmdlines are NUL-separated (with a trailing NUL). A space-joined string
127
+ (test convenience / odd launchers) is tolerated by falling back to a shell
128
+ split when no NUL boundaries are present.
129
+ """
130
+ parts = [p for p in cmdline.split("\0") if p]
131
+ if len(parts) <= 1 and cmdline.strip() and " " in cmdline.strip():
132
+ try:
133
+ parts = shlex.split(cmdline)
134
+ except ValueError:
135
+ parts = cmdline.split()
136
+ return parts
137
+
138
+
139
+ def _flag_value(argv: list[str], flag: str) -> str | None:
140
+ """Value of `--flag value` or `--flag=value` in argv; None if absent/empty."""
141
+ prefix = flag + "="
142
+ for i, tok in enumerate(argv):
143
+ if tok == flag:
144
+ return argv[i + 1] if i + 1 < len(argv) else None
145
+ if tok.startswith(prefix):
146
+ return tok[len(prefix):] or None
147
+ return None
148
+
149
+
150
+ def _match_rc_cmdline(comm: str, cmdline: str) -> ProcRC | None:
151
+ """PURE matcher (no IO): is this argv a Claude project RC server? (AC5)
152
+
153
+ Matches iff the program basename is `claude` AND a bare `remote-control`
154
+ subcommand token is present AND a `--name <name>` flag is parseable. `comm`
155
+ is accepted for signature completeness but deliberately NOT trusted on its
156
+ own. Returns a `ProcRC` (pid=0, filled by the scanner) or None.
157
+ """
158
+ argv = _split_cmdline(cmdline)
159
+ if not argv:
160
+ return None
161
+ if os.path.basename(argv[0]) != "claude":
162
+ return None
163
+ if "remote-control" not in argv[1:]:
164
+ return None
165
+ name = _flag_value(argv, "--name")
166
+ if not name:
167
+ return None
168
+ return ProcRC(pid=0, name=name)
169
+
170
+
171
+ def _read_text(path: str) -> str:
172
+ try:
173
+ with open(path, errors="ignore") as fh:
174
+ return fh.read()
175
+ except Exception:
176
+ return ""
177
+
178
+
179
+ def _read_link(path: str) -> str:
180
+ try:
181
+ return os.readlink(path)
182
+ except Exception:
183
+ return ""
184
+
185
+
186
+ def scan_rc_servers() -> list[ProcRC]:
187
+ """Walk `/proc` for Claude project RC server processes (R5).
188
+
189
+ Reads each pid's `comm` + `cmdline`, runs the pure `_match_rc_cmdline`, and
190
+ fills `pid` + `cwd` (`readlink /proc/<pid>/cwd`) for matches. Degrades to
191
+ `[]` off Linux (no `/proc`) and swallows all per-pid errors.
192
+ """
193
+ if not has_proc():
194
+ return []
195
+ servers: list[ProcRC] = []
196
+ try:
197
+ entries = os.listdir(_PROC)
198
+ except Exception:
199
+ return []
200
+ for entry in entries:
201
+ if not entry.isdigit():
202
+ continue
203
+ try:
204
+ pid = int(entry)
205
+ comm = _read_text(f"{_PROC}/{pid}/comm").strip()
206
+ cmdline = _read_text(f"{_PROC}/{pid}/cmdline")
207
+ match = _match_rc_cmdline(comm, cmdline)
208
+ if match is None:
209
+ continue
210
+ cwd = _read_link(f"{_PROC}/{pid}/cwd")
211
+ servers.append(ProcRC(pid=pid, name=match.name, cwd=cwd or match.cwd))
212
+ except Exception:
213
+ continue
214
+ return servers
@@ -0,0 +1,411 @@
1
+ """RC workspace management — manage Claude Code Remote Control via tmux."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ import time
11
+
12
+ from ..config import cfg
13
+ from ..models import EnvRecord, RCProject, RCServer
14
+ from . import environments, proc
15
+
16
+ # Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
17
+ _ENV_ID_RE = re.compile(r"env_[A-Za-z0-9]+")
18
+
19
+
20
+ def _ensure_list() -> None:
21
+ os.makedirs(cfg.config_dir, exist_ok=True)
22
+ if not cfg.rc_list.is_file():
23
+ cfg.rc_list.touch()
24
+
25
+
26
+ def list_enabled() -> list[str]:
27
+ _ensure_list()
28
+ try:
29
+ return [
30
+ line.strip() for line in cfg.rc_list.read_text().splitlines()
31
+ if line.strip() and not line.strip().startswith("#")
32
+ ]
33
+ except FileNotFoundError:
34
+ return []
35
+
36
+
37
+ def list_has(proj: str) -> bool:
38
+ return proj in list_enabled()
39
+
40
+
41
+ def list_add(proj: str) -> None:
42
+ _ensure_list()
43
+ if list_has(proj):
44
+ return
45
+ with open(cfg.rc_list, "a") as f:
46
+ f.write(f"{proj}\n")
47
+
48
+
49
+ def list_rm(proj: str) -> None:
50
+ _ensure_list()
51
+ try:
52
+ lines = cfg.rc_list.read_text().splitlines(keepends=True)
53
+ with open(cfg.rc_list, "w") as f:
54
+ for line in lines:
55
+ if line.strip() != proj:
56
+ f.write(line)
57
+ except FileNotFoundError:
58
+ pass
59
+
60
+
61
+ def toggle_autostart(proj: str) -> bool:
62
+ """Toggle project in the autostart list. Returns new state."""
63
+ if list_has(proj):
64
+ list_rm(proj)
65
+ return False
66
+ list_add(proj)
67
+ return True
68
+
69
+
70
+ def _load_projects() -> dict:
71
+ """Read the `projects` map from ~/.claude.json, or {} on any failure.
72
+
73
+ Single source for the claude.json read shared by trusted_projects /
74
+ is_trusted, so the open+parse+swallow dance lives in one place.
75
+ """
76
+ try:
77
+ with open(cfg.claude_json) as f:
78
+ return json.load(f).get("projects", {}) or {}
79
+ except Exception:
80
+ return {}
81
+
82
+
83
+ def trusted_projects() -> list[str]:
84
+ prefix = str(cfg.workspace) + "/"
85
+ projects = []
86
+ try:
87
+ for key, val in _load_projects().items():
88
+ if val.get("hasTrustDialogAccepted") and key.startswith(prefix):
89
+ name = key[len(prefix):]
90
+ if "/" not in name:
91
+ projects.append(name)
92
+ except Exception:
93
+ return []
94
+ return sorted(projects)
95
+
96
+
97
+ def is_trusted(proj: str) -> bool:
98
+ try:
99
+ key = f"{cfg.workspace}/{proj}"
100
+ return bool(_load_projects().get(key, {}).get("hasTrustDialogAccepted", False))
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ # --- tmux adapter ---------------------------------------------------------
106
+ # Single seam over the tmux CLI. Only `_tmux_run` touches `subprocess`; every
107
+ # other tmux call routes through a verb wrapper. Each wrapper keeps the
108
+ # swallow-errors contract (return empty/False/None on any failure).
109
+
110
+
111
+ def _tmux_run(args: list[str]) -> subprocess.CompletedProcess | None:
112
+ """Run one `tmux <args>` command; return the result, or None on failure."""
113
+ try:
114
+ return subprocess.run(
115
+ ["tmux", *args],
116
+ capture_output=True, text=True, timeout=5,
117
+ )
118
+ except Exception:
119
+ return None
120
+
121
+
122
+ def _tmux_list_windows() -> list[str]:
123
+ cp = _tmux_run(["list-windows", "-t", cfg.rc_session, "-F", "#W"])
124
+ if cp is None:
125
+ return []
126
+ return [line.strip() for line in cp.stdout.splitlines() if line.strip()]
127
+
128
+
129
+ def _tmux_pane_alive(target: str) -> bool:
130
+ cp = _tmux_run(["list-panes", "-t", target, "-F", "#{pane_dead}"])
131
+ if cp is None:
132
+ return False
133
+ return cp.stdout.strip().split("\n")[0] == "0"
134
+
135
+
136
+ def _tmux_window_pids() -> dict[str, int]:
137
+ """{window_name: pane_pid} for the rc session; {} on failure.
138
+
139
+ The pane pid IS the RC server pid because `start_one` runs `exec claude …`
140
+ (the shell is replaced). Used to classify /proc-discovered servers as
141
+ managed (pid in this set) vs external.
142
+ """
143
+ cp = _tmux_run(
144
+ ["list-windows", "-t", cfg.rc_session, "-F", "#W\t#{pane_pid}"]
145
+ )
146
+ if cp is None:
147
+ return {}
148
+ out: dict[str, int] = {}
149
+ for line in cp.stdout.splitlines():
150
+ name, _, pid_s = line.partition("\t")
151
+ name = name.strip()
152
+ try:
153
+ pid = int(pid_s.strip())
154
+ except ValueError:
155
+ continue
156
+ if name:
157
+ out[name] = pid
158
+ return out
159
+
160
+
161
+ def _tmux_capture_pane(target: str) -> str:
162
+ """Full scrollback of a tmux pane as text; "" on failure.
163
+
164
+ Captures from the start of history (`-S -`) so an `env_*` id printed at
165
+ server startup is still grep-able after it scrolls off the visible region.
166
+ """
167
+ cp = _tmux_run(["capture-pane", "-p", "-S", "-", "-t", target])
168
+ if cp is None:
169
+ return ""
170
+ return cp.stdout
171
+
172
+
173
+ def _tmux_has_session(session: str) -> bool:
174
+ cp = _tmux_run(["has-session", "-t", session])
175
+ return cp is not None and cp.returncode == 0
176
+
177
+
178
+ def _tmux_new_window(session: str, name: str, cmd: str) -> bool:
179
+ cp = _tmux_run(["new-window", "-t", session, "-n", name, cmd])
180
+ return cp is not None and cp.returncode == 0
181
+
182
+
183
+ def _tmux_new_session(session: str, name: str, cmd: str) -> bool:
184
+ cp = _tmux_run(["new-session", "-d", "-s", session, "-n", name, cmd])
185
+ return cp is not None and cp.returncode == 0
186
+
187
+
188
+ def _tmux_kill_window(target: str) -> bool:
189
+ cp = _tmux_run(["kill-window", "-t", target])
190
+ return cp is not None and cp.returncode == 0
191
+
192
+
193
+ def _tmux_kill_session(session: str) -> bool:
194
+ cp = _tmux_run(["kill-session", "-t", session])
195
+ return cp is not None and cp.returncode == 0
196
+
197
+
198
+ def _tmux_windows() -> list[str]:
199
+ return _tmux_list_windows()
200
+
201
+
202
+ def _is_alive(proj: str) -> bool:
203
+ return _tmux_pane_alive(f"{cfg.rc_session}:{proj}")
204
+
205
+
206
+ def run_in_tmux(session: str, window: str, cmd: str) -> bool:
207
+ """Run `cmd` in a tmux `window` under `session`, creating the session if
208
+ it doesn't exist yet. Public seam for relaunching a session outside the
209
+ managed RC server machinery."""
210
+ if _tmux_has_session(session):
211
+ return _tmux_new_window(session, window, cmd)
212
+ return _tmux_new_session(session, window, cmd)
213
+
214
+
215
+ def _read_rc_at_startup(directory: str) -> bool | None:
216
+ for name in ("settings.local.json", "settings.json"):
217
+ path = os.path.join(directory, ".claude", name)
218
+ try:
219
+ with open(path) as f:
220
+ val = json.load(f).get("remoteControlAtStartup")
221
+ if val is not None:
222
+ return bool(val)
223
+ except Exception:
224
+ continue
225
+ return None
226
+
227
+
228
+ def _read_spawn_mode(proj: str) -> str | None:
229
+ """Project `remoteControlSpawnMode` from ~/.claude.json, or None if unset.
230
+
231
+ Lives in the same `projects` map as `hasTrustDialogAccepted` (verified on
232
+ disk), so it reuses `_load_projects` rather than re-opening claude.json.
233
+ """
234
+ try:
235
+ key = f"{cfg.workspace}/{proj}"
236
+ val = _load_projects().get(key, {}).get("remoteControlSpawnMode")
237
+ return str(val) if val else None
238
+ except Exception:
239
+ return None
240
+
241
+
242
+ def set_rc_at_startup(directory: str, value: bool | None) -> None:
243
+ settings_dir = os.path.join(directory, ".claude")
244
+ path = os.path.join(settings_dir, "settings.local.json")
245
+ os.makedirs(settings_dir, exist_ok=True)
246
+ try:
247
+ with open(path) as f:
248
+ data = json.load(f)
249
+ except Exception:
250
+ data = {}
251
+ if value is None:
252
+ data.pop("remoteControlAtStartup", None)
253
+ else:
254
+ data["remoteControlAtStartup"] = value
255
+ with open(path, "w") as f:
256
+ json.dump(data, f, indent=2)
257
+ f.write("\n")
258
+
259
+
260
+ def scan() -> list[RCProject]:
261
+ enabled = set(list_enabled())
262
+ trusted = trusted_projects()
263
+ windows = set(_tmux_windows())
264
+ all_names = sorted(set(trusted) | enabled)
265
+
266
+ result: list[RCProject] = []
267
+ for name in all_names:
268
+ directory = str(cfg.workspace / name)
269
+ in_windows = name in windows
270
+ if in_windows:
271
+ status = "running" if _is_alive(name) else "dead"
272
+ else:
273
+ status = "stopped"
274
+ result.append(RCProject(
275
+ name=name, directory=directory,
276
+ trusted=name in trusted,
277
+ in_list=name in enabled,
278
+ status=status,
279
+ auto_start=name in enabled,
280
+ rc_at_startup=_read_rc_at_startup(directory),
281
+ spawn_mode=_read_spawn_mode(name),
282
+ ))
283
+ return result
284
+
285
+
286
+ def _split_env(env_id: str) -> tuple[str, str]:
287
+ """`env_abc` -> (`env`, `abc`); ("", "") when not a namespaced id."""
288
+ prefix, sep, suffix = env_id.partition("_")
289
+ if not sep or not prefix or not suffix:
290
+ return "", ""
291
+ return prefix, suffix
292
+
293
+
294
+ def _capture_env_id(target: str) -> str:
295
+ """Grep an `env_*` cloud id from a managed server's pane output, or "".
296
+
297
+ The project RC server leaves zero structured footprint; its cloud env id is
298
+ only printed to stdout (`environment=env_…`). This is the single signal we
299
+ can capture locally for the ledger.
300
+ """
301
+ m = _ENV_ID_RE.search(_tmux_capture_pane(target))
302
+ return m.group(0) if m else ""
303
+
304
+
305
+ def scan_servers() -> list[RCServer]:
306
+ """All project RC servers: managed (csctl tmux) ∪ external (/proc) — R5/D5.
307
+
308
+ Managed = tmux windows in `cfg.rc_session` (their pane pid IS the server
309
+ pid); external = `/proc`-discovered `claude remote-control --name` processes
310
+ NOT owned by a managed pane. External servers are READ-ONLY (no
311
+ takeover/restart — review gate; sustains the "no auto-restart RC" rule).
312
+
313
+ For managed servers the captured `env_*` cloud id is pushed one-way into the
314
+ ledger via `environments.upsert` (rc → environments only; environments never
315
+ imports rc). Swallows errors → returns whatever it assembled.
316
+ """
317
+ try:
318
+ window_pids = _tmux_window_pids()
319
+ discovered = proc.scan_rc_servers()
320
+ except Exception:
321
+ return []
322
+
323
+ by_pid = {p.pid: p for p in discovered}
324
+ managed_pid_set = set(window_pids.values())
325
+
326
+ servers: list[RCServer] = []
327
+ env_records: list[EnvRecord] = []
328
+
329
+ # Managed windows first — tmux is the authority for "managed".
330
+ for window, pid in window_pids.items():
331
+ target = f"{cfg.rc_session}:{window}"
332
+ status = "running" if _tmux_pane_alive(target) else "dead"
333
+ found = by_pid.get(pid)
334
+ env_id = _capture_env_id(target)
335
+ if env_id:
336
+ prefix, key = _split_env(env_id)
337
+ if prefix and key:
338
+ env_records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
339
+ servers.append(RCServer(
340
+ name=found.name if found else window,
341
+ cwd=found.cwd if found else "",
342
+ managed=True,
343
+ pid=pid or None,
344
+ env_id=env_id or None,
345
+ status=status,
346
+ ))
347
+
348
+ # External — discovered procs not owned by any managed pane.
349
+ for p in discovered:
350
+ if p.pid in managed_pid_set:
351
+ continue
352
+ servers.append(RCServer(
353
+ name=p.name, cwd=p.cwd, managed=False,
354
+ pid=p.pid or None, env_id=None, status="running",
355
+ ))
356
+
357
+ if env_records:
358
+ environments.upsert(env_records)
359
+ return servers
360
+
361
+
362
+ def start_one(proj: str) -> bool:
363
+ directory = cfg.workspace / proj
364
+ if not directory.is_dir():
365
+ return False
366
+ if not is_trusted(proj):
367
+ return False
368
+ if proj in _tmux_windows():
369
+ if _is_alive(proj):
370
+ return False
371
+ if not stop_one(proj):
372
+ return False
373
+
374
+ remote_name = f"ws/{proj}"
375
+ # Each fresh Remote Control process registers a distinct cloud environment.
376
+ # Keep restart explicit so transient exits do not pile up duplicate mobile
377
+ # environment entries with the same display name.
378
+ cmd = (
379
+ f"cd {shlex.quote(str(directory))} && exec claude remote-control "
380
+ f"--name {shlex.quote(remote_name)} --spawn same-dir"
381
+ )
382
+
383
+ session = cfg.rc_session
384
+ has_session = _tmux_has_session(session)
385
+
386
+ if has_session:
387
+ return _tmux_new_window(session, proj, cmd)
388
+ return _tmux_new_session(session, proj, cmd)
389
+
390
+
391
+ def stop_one(proj: str) -> bool:
392
+ return _tmux_kill_window(f"{cfg.rc_session}:{proj}")
393
+
394
+
395
+ def stop_all() -> bool:
396
+ return _tmux_kill_session(cfg.rc_session)
397
+
398
+
399
+ def start_many(projects: list[str]) -> int:
400
+ count = 0
401
+ for proj in projects:
402
+ if count > 0:
403
+ time.sleep(cfg.rc_stagger)
404
+ if start_one(proj):
405
+ count += 1
406
+ return count
407
+
408
+
409
+ def start_all_listed() -> int:
410
+ """Start every project currently enabled in the autostart list."""
411
+ return start_many(list_enabled())