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.
- cc_session_control/__init__.py +3 -0
- cc_session_control/__main__.py +5 -0
- cc_session_control/actions/__init__.py +0 -0
- cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control/actions/session_ops.py +150 -0
- cc_session_control/app.py +264 -0
- cc_session_control/cli.py +288 -0
- cc_session_control/clipboard.py +44 -0
- cc_session_control/config.py +132 -0
- cc_session_control/data/__init__.py +0 -0
- cc_session_control/data/agents.py +12 -0
- cc_session_control/data/cleanup.py +402 -0
- cc_session_control/data/environments.py +444 -0
- cc_session_control/data/liveness.py +140 -0
- cc_session_control/data/proc.py +214 -0
- cc_session_control/data/rc.py +411 -0
- cc_session_control/data/registry.py +155 -0
- cc_session_control/data/sessions.py +188 -0
- cc_session_control/data/snapshot.py +115 -0
- cc_session_control/models.py +170 -0
- cc_session_control/views/__init__.py +0 -0
- cc_session_control/views/_session_row.py +145 -0
- cc_session_control/views/agents.py +293 -0
- cc_session_control/views/rc.py +374 -0
- cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0.dist-info/METADATA +115 -0
- cc_session_control-0.4.0.dist-info/RECORD +31 -0
- cc_session_control-0.4.0.dist-info/WHEEL +5 -0
- cc_session_control-0.4.0.dist-info/entry_points.txt +2 -0
- cc_session_control-0.4.0.dist-info/licenses/LICENSE +21 -0
- cc_session_control-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|