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,155 @@
1
+ """Read Claude Code's session/agent registries — pure parse, ~5s TTL cache.
2
+
3
+ Two on-disk registers Claude Code maintains itself:
4
+ - `sessions/<pid>.json` → one per local runtime (a sid can have several)
5
+ - `jobs/<short>/state.json` → one per background agent (NO pid inside)
6
+
7
+ Both readers swallow errors and return `[]` so the TUI never crashes on a
8
+ malformed/absent register. Results are cached for ~5s (mirrors
9
+ `liveness.alive_map`) so the shared world snapshot can reuse them; pass
10
+ `max_age=0.0` to force a fresh read (used by tests that swap `cfg` paths).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import glob
16
+ import json
17
+ import os
18
+ import time
19
+
20
+ from ..config import cfg
21
+ from ..models import AgentJob, SessionProc
22
+
23
+ _sessions_cache: list[SessionProc] | None = None
24
+ _sessions_time: float = 0.0
25
+ _jobs_cache: list[AgentJob] | None = None
26
+ _jobs_time: float = 0.0
27
+
28
+
29
+ def invalidate_cache() -> None:
30
+ """Drop both cached reads (next call re-scans disk)."""
31
+ global _sessions_cache, _jobs_cache
32
+ _sessions_cache = None
33
+ _jobs_cache = None
34
+
35
+
36
+ def _suffix(bridge: str | None) -> str:
37
+ """Namespace suffix of a bridge id (`cse_abc` -> `abc`), or ""."""
38
+ if not bridge or "_" not in bridge:
39
+ return ""
40
+ return bridge.split("_", 1)[1]
41
+
42
+
43
+ def _parse_session_proc(path: str) -> SessionProc | None:
44
+ try:
45
+ with open(path, errors="ignore") as fh:
46
+ d = json.load(fh)
47
+ except Exception:
48
+ return None
49
+ sid = d.get("sessionId")
50
+ pid = d.get("pid")
51
+ if not sid or not pid:
52
+ return None
53
+ try:
54
+ pid_int = int(pid)
55
+ except (TypeError, ValueError):
56
+ return None
57
+ return SessionProc(
58
+ pid=pid_int,
59
+ sid=str(sid),
60
+ cwd=d.get("cwd", "") or "",
61
+ kind=d.get("kind", "") or "",
62
+ entrypoint=d.get("entrypoint", "") or "",
63
+ status=d.get("status", "") or "",
64
+ proc_start=str(d.get("procStart", "") or ""),
65
+ bridge=d.get("bridgeSessionId"),
66
+ )
67
+
68
+
69
+ def read_session_procs(max_age: float = 5.0) -> list[SessionProc]:
70
+ """All parseable `sessions/<pid>.json` entries. Cached for `max_age` secs."""
71
+ global _sessions_cache, _sessions_time
72
+ now = time.monotonic()
73
+ if _sessions_cache is not None and (now - _sessions_time) < max_age:
74
+ return _sessions_cache
75
+ rows: list[SessionProc] = []
76
+ try:
77
+ for path in glob.glob(os.path.join(str(cfg.sessions_dir), "*.json")):
78
+ row = _parse_session_proc(path)
79
+ if row is not None:
80
+ rows.append(row)
81
+ except Exception:
82
+ rows = []
83
+ _sessions_cache = rows
84
+ _sessions_time = now
85
+ return rows
86
+
87
+
88
+ def _parse_agent_job(state_path: str) -> AgentJob | None:
89
+ short = os.path.basename(os.path.dirname(state_path))
90
+ if not short:
91
+ return None
92
+ try:
93
+ with open(state_path, errors="ignore") as fh:
94
+ d = json.load(fh)
95
+ except Exception:
96
+ return None
97
+ sid = str(d.get("sessionId") or "")
98
+ flags = d.get("respawnFlags")
99
+ if not isinstance(flags, list):
100
+ flags = []
101
+ return AgentJob(
102
+ short=short,
103
+ sid=sid,
104
+ resume_sid=str(d.get("resumeSessionId") or sid or ""),
105
+ state=d.get("state", "") or "",
106
+ tempo=d.get("tempo", "") or "",
107
+ cwd=d.get("cwd", "") or "",
108
+ name=d.get("name", "") or "",
109
+ env_suffix=_suffix(d.get("bridgeSessionId")),
110
+ respawn_flags=[str(x) for x in flags],
111
+ )
112
+
113
+
114
+ def read_agent_jobs(max_age: float = 5.0) -> list[AgentJob]:
115
+ """All parseable `jobs/<short>/state.json` records. Cached for `max_age`."""
116
+ global _jobs_cache, _jobs_time
117
+ now = time.monotonic()
118
+ if _jobs_cache is not None and (now - _jobs_time) < max_age:
119
+ return _jobs_cache
120
+ rows: list[AgentJob] = []
121
+ try:
122
+ pattern = os.path.join(str(cfg.jobs_dir), "*", "state.json")
123
+ for state_path in glob.glob(pattern):
124
+ row = _parse_agent_job(state_path)
125
+ if row is not None:
126
+ rows.append(row)
127
+ except Exception:
128
+ rows = []
129
+ _jobs_cache = rows
130
+ _jobs_time = now
131
+ return rows
132
+
133
+
134
+ def host_pid_for_sid(
135
+ sid: str, session_procs: list[SessionProc]
136
+ ) -> tuple[int | None, bool]:
137
+ """Join a sid to its host pid via the registry session files — PURE.
138
+
139
+ `jobs/<short>/state.json` carries NO pid, so a background/agent worker's host
140
+ pid is the `sessions/<pid>.json` entry sharing the sid. Prefers a proc-alive
141
+ match (so `alive=True` is trustworthy and defeats pid reuse); falls back to
142
+ the first sid match with `alive=False`. Relies on each `SessionProc.proc_alive`
143
+ already being injected by the caller (no IO here). Returns `(None, False)`
144
+ when no sessions file references the sid (that live worker is unstoppable).
145
+
146
+ The single host-pid join shared by `snapshot._enrich_jobs`,
147
+ `actions.agent_ops.job_host`, and `cleanup.remove_session` (M3 guard).
148
+ """
149
+ procs = [sp for sp in session_procs if sp.sid == sid]
150
+ if not procs:
151
+ return None, False
152
+ for sp in procs:
153
+ if sp.proc_alive:
154
+ return sp.pid, True
155
+ return procs[0].pid, False
@@ -0,0 +1,188 @@
1
+ """Session scanning — scan Claude Code transcripts and determine status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+ from dataclasses import replace
9
+
10
+ from ..config import cfg
11
+ from ..models import LiveInfo, Session
12
+ from . import registry
13
+ from .liveness import _is_rc_exposed, alive_map, live_index
14
+ from .proc import ancestor_pids as _ancestor_pids # /proc walk moved to proc.py
15
+ from .proc import pid_alive
16
+
17
+ _NOISE = (
18
+ "<command-message>", "<command-name>", "<command-args>",
19
+ "<local-command-caveat>", "<local-command-stdout>", "<local-command-stderr>",
20
+ "<system-reminder>", "caveat:",
21
+ )
22
+
23
+
24
+ def _is_noise(t: str) -> bool:
25
+ t = t.strip().lower()
26
+ return (not t) or any(t.startswith(n) for n in _NOISE)
27
+
28
+
29
+ def _clean_text(t: str) -> str:
30
+ t = " ".join(t.split())
31
+ for marker in ("<system-reminder", "<command-message", "<command-name",
32
+ "<command-args", "<local-command-"):
33
+ i = t.find(marker)
34
+ if i != -1:
35
+ t = t[:i]
36
+ return t.strip()
37
+
38
+
39
+ def _parse_transcript(
40
+ path: str,
41
+ idx: dict[str, LiveInfo],
42
+ cur: set[int],
43
+ job_shorts: set[str],
44
+ ) -> Session | None:
45
+ """Parse one transcript .jsonl into a Session, or None if it has no cwd.
46
+
47
+ `idx` is the joined live index (sid -> LiveInfo from `live_index()`), `cur`
48
+ the ancestor-pid set, and `job_shorts` the set of background-agent short ids
49
+ (`sid[:8]`); all injected so this stays unit-testable. The substring
50
+ pre-check before json.loads is kept intact for performance.
51
+ """
52
+ sid = os.path.basename(path)[:-6]
53
+ try:
54
+ st = os.stat(path)
55
+ except OSError:
56
+ return None
57
+
58
+ cwd = title = last_prompt = first_prompt = ""
59
+ hidden: set[str] = set()
60
+ prompts = 0
61
+
62
+ try:
63
+ with open(path, "r", errors="ignore") as fh:
64
+ for line in fh:
65
+ if '"sdk-ts"' in line:
66
+ hidden.add("sdk")
67
+ if '"bridge-session"' in line:
68
+ hidden.add("bridge")
69
+ if not cwd and '"cwd"' in line:
70
+ try:
71
+ cwd = json.loads(line).get("cwd", "") or cwd
72
+ except Exception:
73
+ pass
74
+ if '"aiTitle"' in line:
75
+ try:
76
+ title = json.loads(line).get("aiTitle", title) or title
77
+ except Exception:
78
+ pass
79
+ if '"lastPrompt"' in line:
80
+ try:
81
+ last_prompt = json.loads(line).get("lastPrompt", last_prompt) or last_prompt
82
+ except Exception:
83
+ pass
84
+ if '"type":"user"' in line:
85
+ try:
86
+ o = json.loads(line)
87
+ except Exception:
88
+ continue
89
+ if o.get("type") != "user":
90
+ continue
91
+ c = (o.get("message") or {}).get("content")
92
+ if isinstance(c, str):
93
+ texts = [c]
94
+ elif isinstance(c, list):
95
+ texts = [b.get("text", "") for b in c
96
+ if isinstance(b, dict) and b.get("type") == "text"]
97
+ else:
98
+ texts = []
99
+ texts = [t for t in texts if t.strip()]
100
+ if texts:
101
+ prompts += 1
102
+ if not first_prompt:
103
+ for t in texts:
104
+ if _is_noise(t):
105
+ continue
106
+ ct = _clean_text(t)
107
+ if ct:
108
+ first_prompt = ct
109
+ break
110
+ except Exception:
111
+ pass
112
+
113
+ if not cwd:
114
+ return None
115
+
116
+ lp = "" if _is_noise(last_prompt) else last_prompt
117
+ label = title or first_prompt or lp or "(untitled)"
118
+
119
+ # Join the merged liveness/identity for this sid. Missing => dead, no
120
+ # registry data (transcript-only): liveness stays False and the registry-
121
+ # derived fields stay empty.
122
+ info = idx.get(sid)
123
+ if info is not None:
124
+ pid = info.pid
125
+ alive = info.alive
126
+ kind = info.kind
127
+ entrypoint = info.entrypoint
128
+ source = info.source
129
+ status = info.status
130
+ bridge = info.bridge
131
+ # "current" must protect ANY of the sid's alive pids: a resumed session
132
+ # has several pids and csctl may have been launched by an older one that
133
+ # is NOT the newest `pid` chosen for display. Fall back to the single
134
+ # chosen pid for hand-constructed LiveInfo with no `pids` list.
135
+ cand = info.pids if info.pids else ([pid] if pid else [])
136
+ current = any(p in cur for p in cand)
137
+ proc_alive = info.proc_alive
138
+ else:
139
+ pid = None
140
+ alive = False
141
+ kind = entrypoint = source = status = ""
142
+ bridge = None
143
+ current = False
144
+ proc_alive = False
145
+
146
+ rc_exposed = _is_rc_exposed(bridge, proc_alive)
147
+
148
+ return Session(
149
+ sid=sid, cwd=cwd, label=label, mtime=st.st_mtime,
150
+ prompts=prompts, pid=pid,
151
+ alive=alive,
152
+ current=current,
153
+ hidden=hidden, file=path,
154
+ kind=kind, entrypoint=entrypoint, source=source,
155
+ rc_exposed=rc_exposed,
156
+ env_id=bridge if rc_exposed else None,
157
+ agent_short=sid[:8] if sid[:8] in job_shorts else None,
158
+ status=status,
159
+ )
160
+
161
+
162
+ def scan() -> list[Session]:
163
+ """Unified transcript-driven session scan.
164
+
165
+ Merges the three liveness/identity sources once per scan — registry
166
+ `sessions/<pid>.json`, `claude agents --json`, and `jobs/*/state.json` — then
167
+ projects each transcript through `live_index()` to fill source/liveness/
168
+ rc-exposure. Scan stays transcript-driven: an agent-only sid (present in the
169
+ live index but with no transcript) is surfaced by the Agents tab, not here.
170
+ """
171
+ root = str(cfg.projects_root)
172
+ session_procs = [
173
+ replace(sp, proc_alive=pid_alive(sp.pid, sp.proc_start))
174
+ for sp in registry.read_session_procs()
175
+ ]
176
+ agents = alive_map()
177
+ idx = live_index(session_procs, agents)
178
+ job_shorts = {j.short for j in registry.read_agent_jobs()}
179
+ cur = _ancestor_pids()
180
+ rows: list[Session] = []
181
+
182
+ for f in glob.glob(os.path.join(root, "*", "*.jsonl")):
183
+ row = _parse_transcript(f, idx, cur, job_shorts)
184
+ if row is not None:
185
+ rows.append(row)
186
+
187
+ rows.sort(key=lambda r: r.mtime, reverse=True)
188
+ return rows
@@ -0,0 +1,115 @@
1
+ """Shared world snapshot — ONE scan per refresh cycle (R11 / D8).
2
+
3
+ The async refresh used to call `fetch_pending()` on every view, so three tabs
4
+ each re-scanned `/proc`, the transcripts, and the registries. `build_world_snapshot`
5
+ computes that world ONCE on the worker thread; `App` then hands the same
6
+ immutable snapshot to each view's `fetch_pending(snapshot)` so they only project
7
+ it (no per-view IO). Views stay back-compatible: `fetch_pending(None)` self-fetches.
8
+
9
+ This is the TOP of the data layer — it composes `sessions` / `rc` / `registry` /
10
+ `environments` / `proc`. Nothing in `data/` imports it (only `app`/`views` do),
11
+ so there is no cycle. Errors are swallowed by the callees; `App` additionally
12
+ guards `build_world_snapshot` so a failed build degrades to per-view self-fetch.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field, replace
18
+
19
+ from ..models import AgentJob, EnvRecord, RCProject, RCServer, Session, SessionProc
20
+ from . import environments, liveness, proc, rc, registry, sessions
21
+
22
+
23
+ @dataclass
24
+ class WorldSnapshot:
25
+ """One cycle's shared view of the machine (read-only data for the views).
26
+
27
+ `sessions` is the full transcript-driven scan (SessionsView), `agent_jobs`
28
+ the background jobs enriched with host liveness (AgentsView), and
29
+ `rc_projects`/`rc_servers` the Remote Control world (RCView). The two env
30
+ sets are the bridge-environment ledger's two tiers (R6):
31
+ - `observed_envs` — ALIVE-GATED (`observe_live`): the CURRENT/bound display.
32
+ - `file_referenced_envs` — bridge-truthy (`observe`): ledger MEMBERSHIP, and
33
+ the set orphans are computed against (`orphan = ledger − file-referenced`).
34
+
35
+ `session_procs` (with `/proc` liveness already injected), `agents_map`
36
+ (`claude agents --json`) and `cur` (the ancestor-pid set) are the raw liveness
37
+ inputs `build_world_snapshot` already computes for the scan; they are exposed
38
+ here so the Sessions cleanup submenu can feed `cleanup_classified` /
39
+ `select_zombie_pids` WITHOUT a second scan (R11/D8).
40
+ """
41
+ sessions: list[Session] = field(default_factory=list)
42
+ agent_jobs: list[AgentJob] = field(default_factory=list)
43
+ rc_projects: list[RCProject] = field(default_factory=list)
44
+ rc_servers: list[RCServer] = field(default_factory=list)
45
+ observed_envs: list[EnvRecord] = field(default_factory=list)
46
+ file_referenced_envs: list[EnvRecord] = field(default_factory=list)
47
+ session_procs: list[SessionProc] = field(default_factory=list)
48
+ agents_map: dict[str, int | None] = field(default_factory=dict)
49
+ cur: set[int] = field(default_factory=set)
50
+
51
+
52
+ def _enrich_jobs(
53
+ jobs: list[AgentJob], session_procs: list[SessionProc]
54
+ ) -> list[AgentJob]:
55
+ """Fill each job's `host_pid`/`host_alive` by joining sid -> sessions/<pid>.
56
+
57
+ `state.json` carries no pid, so a live worker's host pid is the proc-alive
58
+ `sessions/<pid>.json` for the job's sid (falling back to the first match when
59
+ none is alive). Uses the single `registry.host_pid_for_sid` join (shared with
60
+ `agent_ops.job_host`) and returns fresh copies so the cached registry objects
61
+ are never mutated. `session_procs` must already carry injected `proc_alive`
62
+ (build_world_snapshot does this).
63
+ """
64
+ out: list[AgentJob] = []
65
+ for job in jobs:
66
+ pid, alive = registry.host_pid_for_sid(job.sid, session_procs)
67
+ out.append(replace(job, host_pid=pid, host_alive=alive))
68
+ return out
69
+
70
+
71
+ def build_world_snapshot() -> WorldSnapshot:
72
+ """Compute the shared per-cycle world once (worker thread, R11/D8).
73
+
74
+ Heavy scans (transcript glob via `sessions.scan`, `/proc` walk via
75
+ `rc.scan_servers`) run exactly once here instead of once per tab. The
76
+ registry reads are ~5s-TTL cached so the few repeat reads inside `scan()`
77
+ hit the cache. Each callee swallows its own errors and returns safe empties.
78
+ """
79
+ session_procs = [
80
+ replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
81
+ for sp in registry.read_session_procs()
82
+ ]
83
+ all_sessions = sessions.scan()
84
+ agent_jobs = _enrich_jobs(registry.read_agent_jobs(), session_procs)
85
+ # Cheap, cached/inexpensive liveness inputs surfaced for the cleanup submenu
86
+ # (the registry read above + scan() already warmed alive_map's 5s cache).
87
+ agents_map = liveness.alive_map()
88
+ cur = proc.ancestor_pids()
89
+ rc_projects = rc.scan()
90
+ rc_servers = rc.scan_servers()
91
+ # R6 ledger persistence (the whole point of the ledger): record EVERY env an
92
+ # on-disk file references THIS cycle — session_* + cse_* + the env_* captured
93
+ # from rc servers — using the bridge-truthy (NOT alive-gated) set for
94
+ # membership. When one of these later toggles away (RC turned off / job
95
+ # removed / server stopped) it stays in the ledger but drops out of the
96
+ # file-referenced set, surfacing as an orphan / manual-delete candidate. Cheap
97
+ # and safe on the worker thread: the ledger is write-on-change + flock +
98
+ # compacted, so re-observing the same set is a no-op rewrite.
99
+ file_referenced_envs = environments.observe(session_procs, agent_jobs, rc_servers)
100
+ environments.upsert(file_referenced_envs)
101
+ # CURRENT must be alive-gated (R3/R6): pass the already-liveness-resolved
102
+ # session_procs + host-enriched agent_jobs + running servers so a zombie's
103
+ # stale bridge is NOT counted as a bound (current) environment.
104
+ observed_envs = environments.observe_live(session_procs, agent_jobs, rc_servers)
105
+ return WorldSnapshot(
106
+ sessions=all_sessions,
107
+ agent_jobs=agent_jobs,
108
+ rc_projects=rc_projects,
109
+ rc_servers=rc_servers,
110
+ observed_envs=observed_envs,
111
+ file_referenced_envs=file_referenced_envs,
112
+ session_procs=session_procs,
113
+ agents_map=agents_map,
114
+ cur=cur,
115
+ )
@@ -0,0 +1,170 @@
1
+ """Data models for cc-session-control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+ # Single source of truth for RC status values. The Chinese display labels
9
+ # (views/rc.py) and the CLI icons (cli.py) are presentation-only maps keyed
10
+ # off this vocabulary.
11
+ Status = Literal["running", "dead", "stopped"]
12
+
13
+
14
+ @dataclass
15
+ class Session:
16
+ sid: str
17
+ cwd: str
18
+ label: str
19
+ mtime: float
20
+ prompts: int
21
+ pid: int | None
22
+ alive: bool
23
+ current: bool
24
+ hidden: set[str] = field(default_factory=set)
25
+ file: str = ""
26
+ # Unified-workbench fields (all default so existing construction stays valid).
27
+ kind: str = "" # registry `kind` (e.g. interactive / bg)
28
+ entrypoint: str = "" # registry `entrypoint` (cli / claude-vscode / sdk-ts)
29
+ source: str = "" # coarse bucket: cli / vscode / sdk / bg
30
+ rc_exposed: bool = False # session remote control currently exposed
31
+ env_id: str | None = None # bound bridge environment id, if any
32
+ agent_short: str | None = None # linked background-agent short id, if any
33
+ status: str = "" # registry `status` (busy / idle)
34
+
35
+ @property
36
+ def bridge_or_sdk(self) -> bool:
37
+ """D9: union of the transcript `hidden` tags and registry source==sdk.
38
+
39
+ The 桥接/SDK hide filter (Phase 7) keys off this so the badge and the
40
+ `h` toggle stay consistent whether the SDK signal arrived from the
41
+ transcript marker (`hidden`) or the registry entrypoint (`source`).
42
+ Kept here so the two signals never contradict at the model level.
43
+ """
44
+ return bool(self.hidden) or self.source == "sdk"
45
+
46
+
47
+ @dataclass
48
+ class SessionProc:
49
+ """One `sessions/<pid>.json` registry entry (a session's local runtime).
50
+
51
+ A single sessionId may have several of these — resume keeps the sid but
52
+ mints a new pid. `proc_start` defeats pid reuse (compared to /proc stat).
53
+ """
54
+ pid: int
55
+ sid: str
56
+ cwd: str = ""
57
+ kind: str = ""
58
+ entrypoint: str = ""
59
+ status: str = ""
60
+ proc_start: str = "" # registry `procStart` (kernel starttime, as str)
61
+ proc_alive: bool = False # injected /proc liveness, never parsed from JSON
62
+ bridge: str | None = None # `bridgeSessionId` (session_* namespace)
63
+
64
+
65
+ @dataclass
66
+ class AgentJob:
67
+ """One `jobs/<short>/state.json` background-agent record.
68
+
69
+ state.json carries NO pid; `host_pid`/`host_alive` are filled later by
70
+ joining `sid -> sessions/<pid>.json` (see Phase 6).
71
+ """
72
+ short: str
73
+ sid: str
74
+ resume_sid: str
75
+ state: str = ""
76
+ tempo: str = ""
77
+ cwd: str = ""
78
+ name: str = ""
79
+ env_suffix: str = "" # suffix of the cse_* bridge id
80
+ respawn_flags: list[str] = field(default_factory=list)
81
+ host_pid: int | None = None
82
+ host_alive: bool = False
83
+
84
+
85
+ @dataclass
86
+ class LiveInfo:
87
+ """Merged liveness/identity for one sessionId (output of live_index)."""
88
+ sid: str
89
+ pid: int | None = None
90
+ proc_start: str = ""
91
+ status: str = ""
92
+ kind: str = ""
93
+ entrypoint: str = ""
94
+ bridge: str | None = None
95
+ source: str = ""
96
+ alive: bool = False
97
+ proc_alive: bool = False
98
+ # All proc-confirmed alive pids for this sid (resume mints new pids while
99
+ # keeping the sid). `pid` is the chosen one for display; `pids` is the full
100
+ # candidate set so "current" detection protects ANY ancestor pid, not just
101
+ # the newest (multi-pid under-protection fix).
102
+ pids: list[int] = field(default_factory=list)
103
+
104
+
105
+ @dataclass
106
+ class RCProject:
107
+ name: str
108
+ directory: str
109
+ trusted: bool
110
+ in_list: bool
111
+ status: Status
112
+ auto_start: bool
113
+ rc_at_startup: bool | None = None # per-project remoteControlAtStartup override
114
+ spawn_mode: str | None = None # per-project remoteControlSpawnMode (None=unset)
115
+
116
+
117
+ @dataclass
118
+ class RCServer:
119
+ """A project RC server process (`claude remote-control --name`) — R5/D5.
120
+
121
+ Discovered from tmux (managed) and/or `/proc` (external). `managed` is True
122
+ when the pid belongs to a csctl-managed tmux pane; otherwise the server was
123
+ started outside csctl and is READ-ONLY (no takeover/restart — review gate).
124
+ `env_id` is the full cloud bridge id (`env_*`) captured from a managed
125
+ server's pane output, or None when unknown / external.
126
+ """
127
+ name: str
128
+ cwd: str = ""
129
+ managed: bool = False
130
+ pid: int | None = None
131
+ env_id: str | None = None
132
+ status: Status = "stopped"
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class EnvRecord:
137
+ """One live observation of a bridge environment (R6, D4).
138
+
139
+ `prefix` is the namespace (`session` / `cse` / `env`); `key` is the suffix
140
+ that is the canonical environment id WITHIN that namespace. `bound_sid` is
141
+ the session this observation is bound to (None for namespaces with no sid).
142
+ Frozen so it is hashable for set membership when splitting current/orphan.
143
+ Built by `environments.observe()` (registry) and, in Phase 5, pushed in by
144
+ `rc` for the `env_*` namespace — environments never imports rc.
145
+ """
146
+ prefix: str
147
+ key: str
148
+ bound_sid: str | None = None
149
+
150
+
151
+ @dataclass
152
+ class BridgeEnv:
153
+ """A ledger entry for one bridge environment (R6, D4).
154
+
155
+ `status` is NOT persisted — it is recomputed against the current observation
156
+ by `current_envs`/`orphan_envs` (an orphan is a manual-delete candidate on
157
+ claude.ai/code; there is no local deregister). `first_seen`/`last_seen` are
158
+ epoch seconds. The full namespaced id is `prefix_key`.
159
+ """
160
+ prefix: str
161
+ key: str
162
+ bound_sid: str | None = None
163
+ first_seen: float = 0.0
164
+ last_seen: float = 0.0
165
+ status: Literal["current", "orphan"] = "orphan"
166
+
167
+ @property
168
+ def env_id(self) -> str:
169
+ """Full namespaced id (`cse_<key>` / `session_<key>` / `env_<key>`)."""
170
+ return f"{self.prefix}_{self.key}"
File without changes