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,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
|