cc-session-control 0.6.3__tar.gz → 0.6.4__tar.gz

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.
Files changed (62) hide show
  1. {cc_session_control-0.6.3/src/cc_session_control.egg-info → cc_session_control-0.6.4}/PKG-INFO +1 -1
  2. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/__init__.py +1 -1
  3. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/agent_ops.py +23 -16
  4. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/session_ops.py +63 -58
  5. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/app.py +16 -16
  6. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/cli.py +27 -34
  7. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/cleanup.py +178 -60
  8. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/environments.py +35 -21
  9. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/liveness.py +25 -2
  10. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/registry.py +6 -5
  11. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/sessions.py +3 -0
  12. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/snapshot.py +32 -38
  13. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/models.py +26 -1
  14. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_base.py +5 -4
  15. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_confirm.py +32 -0
  16. cc_session_control-0.6.4/src/cc_session_control/views/_sessions_cleanup.py +175 -0
  17. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/agents.py +12 -29
  18. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/rc.py +5 -7
  19. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/sessions.py +42 -77
  20. {cc_session_control-0.6.3 → cc_session_control-0.6.4/src/cc_session_control.egg-info}/PKG-INFO +1 -1
  21. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_agent_ops.py +11 -4
  22. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_agents.py +4 -6
  23. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_app.py +41 -10
  24. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_cleanup.py +75 -9
  25. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_data.py +51 -0
  26. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_environments.py +42 -13
  27. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_registry.py +8 -1
  28. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_snapshot.py +0 -2
  29. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_views.py +50 -62
  30. cc_session_control-0.6.3/src/cc_session_control/views/_sessions_cleanup.py +0 -161
  31. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/LICENSE +0 -0
  32. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/README.md +0 -0
  33. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/pyproject.toml +0 -0
  34. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/setup.cfg +0 -0
  35. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/__main__.py +0 -0
  36. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/__init__.py +0 -0
  37. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/resume_list.py +0 -0
  38. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/skill_ops.py +0 -0
  39. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/clipboard.py +0 -0
  40. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/config.py +0 -0
  41. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/__init__.py +0 -0
  42. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/proc.py +0 -0
  43. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/rc.py +0 -0
  44. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/skill/SKILL.md +0 -0
  45. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/__init__.py +0 -0
  46. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_colspec.py +0 -0
  47. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_keytable.py +0 -0
  48. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_rows.py +0 -0
  49. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_session_row.py +0 -0
  50. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/SOURCES.txt +0 -0
  51. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  52. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  53. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/requires.txt +0 -0
  54. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/top_level.txt +0 -0
  55. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_cli.py +0 -0
  56. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_liveness.py +0 -0
  57. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_proc.py +0 -0
  58. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_rc.py +0 -0
  59. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_resume_list.py +0 -0
  60. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_sessions.py +0 -0
  61. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_skill_ops.py +0 -0
  62. {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.6.3"
3
+ __version__ = "0.6.4"
@@ -23,12 +23,11 @@ from __future__ import annotations
23
23
 
24
24
  import os
25
25
  import shlex
26
- import signal
27
- import time
28
26
 
29
27
  from ..config import cfg
30
28
  from ..data import cleanup, liveness, proc, rc, registry
31
29
  from ..models import AgentJob, Session
30
+ from . import session_ops
32
31
 
33
32
  # --- host-pid join (shared by stop_job, remove_job, and the view) -------------
34
33
 
@@ -142,11 +141,27 @@ def resume_takeover(job: AgentJob) -> Session:
142
141
  pid=pid,
143
142
  alive=alive,
144
143
  current=current,
144
+ proc_start=_host_start(pid),
145
145
  source="bg",
146
146
  agent_short=job.short,
147
147
  )
148
148
 
149
149
 
150
+ def _host_start(pid: int | None) -> str:
151
+ """`proc_start` of the joined host pid ("" when unknown).
152
+
153
+ `job_host` deliberately keeps its small `(pid, alive)` shape; this second
154
+ lookup feeds `take_over`'s kill-time `pid_alive` recheck so the pid-reuse
155
+ window is closed on the bg-agent path too, not just for scanned sessions.
156
+ """
157
+ if not pid:
158
+ return ""
159
+ return next(
160
+ (sp.proc_start for sp in liveness.live_session_procs() if sp.pid == pid),
161
+ "",
162
+ )
163
+
164
+
150
165
  # --- stop (live workers only) -------------------------------------------------
151
166
 
152
167
  def stop_job(job: AgentJob) -> bool:
@@ -154,23 +169,15 @@ def stop_job(job: AgentJob) -> bool:
154
169
 
155
170
  The host pid is JOINed from `sessions/<pid>.json` (`job_host`); only a
156
171
  confirmed-live pid is killed — a worker with no sessions file is unstoppable
157
- (no-op False, orphan risk). Refuses when "current" can't be determined (no
158
- `/proc`, R10). Owns the liveness-cache invalidation (like terminate). Killing
159
- does not always fully reap a `--remote-control`/bg worker (orphan risk, see
160
- `HELP`).
172
+ (no-op False, orphan risk). The kill itself is `session_ops.take_over` (the
173
+ ONE primitive: R10 gate, recheck, SIGTERM, cache invalidation); the early
174
+ R10 check here just skips the join IO when the answer is already no.
175
+ Killing does not always fully reap a `--remote-control`/bg worker (orphan
176
+ risk, see `HELP`).
161
177
  """
162
178
  if not proc.current_determinable():
163
179
  return False
164
180
  pid, alive = job_host(job)
165
181
  if not alive or not pid:
166
182
  return False
167
- try:
168
- os.kill(pid, signal.SIGTERM)
169
- except ProcessLookupError:
170
- liveness.invalidate_cache() # already gone — liveness changed
171
- return True
172
- except Exception:
173
- return False
174
- time.sleep(1)
175
- liveness.invalidate_cache()
176
- return True
183
+ return session_ops.take_over(pid, _host_start(pid)) in session_ops.TAKE_OVER_OK
@@ -7,39 +7,61 @@ import shlex
7
7
  import signal
8
8
  import time
9
9
  from dataclasses import dataclass
10
+ from typing import Literal
10
11
 
11
12
  from .. import clipboard
12
13
  from ..data import proc, rc
13
14
  from ..data.liveness import invalidate_cache
14
15
  from ..models import Session
15
16
 
17
+ TakeOverResult = Literal["killed", "gone", "refused", "failed"]
18
+
19
+ #: The results a fail-fast caller counts as success (signalled, or nothing
20
+ #: left to signal) — single-sourced so terminate/stop can't diverge on it.
21
+ TAKE_OVER_OK = ("killed", "gone")
16
22
 
17
- def terminate_session(s: Session) -> bool:
18
- """Send SIGTERM and own the liveness-cache invalidation.
19
23
 
20
- Terminating is the one session op that changes `claude agents` liveness,
21
- so it invalidates the alive_map cache itself callers no longer have to
22
- remember to. (delete/cleanup only touch already-dead sessions, so they
23
- don't.)
24
+ def take_over(pid: int, proc_start: str = "") -> TakeOverResult:
25
+ """THE kill primitive behind every takeover/stop: R10 gate kill-time
26
+ liveness recheck SIGTERM settle invalidate the liveness cache.
24
27
 
25
- R10: refuses (returns False) when "current" can't be determined (no
26
- `/proc`) we can't prove `s` is not the launching session, so a SIGTERM
27
- here could hit csctl's own session.
28
+ One implementation so the gate order and the kill semantics cannot fork
29
+ across the resume/terminate/stop variants (they had already started to:
30
+ only terminate/stop skipped the settle sleep for an already-gone pid).
31
+ The recheck (`pid_alive` against `proc_start`; mere existence when the
32
+ start is unknown) closes the pid-reuse window — a confirm modal can sit
33
+ open for minutes, and a recycled pid must never be SIGTERMed.
34
+
35
+ Results: "killed" (signalled + settled), "gone" (already dead / recycled —
36
+ nothing to kill), "refused" (R10: current undeterminable), "failed"
37
+ (signal error, e.g. permissions). Fail-fast callers (terminate/stop) treat
38
+ "failed" as failure; best-effort callers (the resume family) continue.
28
39
  """
29
40
  if not proc.current_determinable():
30
- return False
31
- if not s.pid:
32
- return False
41
+ return "refused"
42
+ if not proc.pid_alive(pid, proc_start):
43
+ invalidate_cache() # already gone — liveness may have changed
44
+ return "gone"
33
45
  try:
34
- os.kill(s.pid, signal.SIGTERM)
46
+ os.kill(pid, signal.SIGTERM)
35
47
  except ProcessLookupError:
36
- invalidate_cache() # already gone — liveness changed
37
- return True
48
+ invalidate_cache()
49
+ return "gone"
38
50
  except Exception:
39
- return False
51
+ return "failed"
40
52
  time.sleep(1)
41
53
  invalidate_cache()
42
- return True
54
+ return "killed"
55
+
56
+
57
+ def terminate_session(s: Session) -> bool:
58
+ """Send SIGTERM via `take_over` (which owns the R10 gate + the
59
+ liveness-cache invalidation — terminating is the one session op that
60
+ changes `claude agents` liveness; delete/cleanup only touch already-dead
61
+ sessions, so they don't invalidate)."""
62
+ if not s.pid:
63
+ return False
64
+ return take_over(s.pid, s.proc_start) in TAKE_OVER_OK
43
65
 
44
66
 
45
67
  def _resume_plan(s: Session, fork: bool = False) -> tuple[str, list[str], bool]:
@@ -90,18 +112,15 @@ def do_resume(s: Session, fork: bool = False) -> None:
90
112
  we never SIGTERM the launching session (every pid looks dead off `/proc`).
91
113
  """
92
114
  cwd, args, should_kill = _resume_plan(s, fork)
93
- if should_kill:
94
- if not proc.current_determinable():
115
+ if should_kill and s.pid:
116
+ if take_over(s.pid, s.proc_start) == "refused":
95
117
  print(
96
118
  "Refused: '/proc' unavailable — cannot determine the current "
97
119
  "session, so the old process can't be safely killed (R10)."
98
120
  )
99
121
  return
100
- try:
101
- os.kill(s.pid, signal.SIGTERM)
102
- except Exception:
103
- pass
104
- time.sleep(1)
122
+ # "gone"/"failed" fall through: the kill is best-effort here, the
123
+ # resume itself must still happen.
105
124
  if cwd and os.path.isdir(cwd):
106
125
  os.chdir(cwd)
107
126
  os.execvp("claude", args)
@@ -121,30 +140,29 @@ def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
121
140
  return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
122
141
 
123
142
 
143
+ def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
144
+ """Kill-if-takeover, then spawn `cmd` in the session's per-project tmux
145
+ session — the shared skeleton behind `relaunch_in_tmux` (R key, with
146
+ --remote-control) and `do_tmux_resume` (t key, foreground): the two
147
+ differed only in the command builder. Returns the exact tmux target,
148
+ or None on R10 refusal ("refused" from `take_over`) / tmux failure;
149
+ "gone"/"failed" fall through like `do_resume` (best-effort kill).
150
+ """
151
+ _, _, should_kill = _resume_plan(s, fork)
152
+ if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
153
+ return None
154
+ return rc.run_in_tmux(rc.session_name_for(s.cwd), s.sid[:8], cmd)
155
+
156
+
124
157
  def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
125
158
  """Relaunch a session as `claude --resume … --remote-control …` inside a
126
159
  tmux window, so it outlives the terminal and is remotely controllable.
127
160
 
128
- A live, non-current session is taken over (its old pid is killed first and
129
- the liveness cache invalidated, like terminate); a fork leaves the original
130
- running. csctl is NOT replaced — it just spawns the tmux window.
131
-
132
- R10: when a takeover kill is required but "current" can't be determined (no
133
- `/proc`), refuse (return False, do not kill or relaunch) — we can't prove `s`
134
- is not the launching session.
161
+ A live, non-current session is taken over (old pid killed first, like
162
+ terminate); a fork leaves the original running. csctl is NOT replaced —
163
+ it just spawns the tmux window.
135
164
  """
136
- _, _, should_kill = _resume_plan(s, fork)
137
- if should_kill and s.pid:
138
- if not proc.current_determinable():
139
- return False
140
- try:
141
- os.kill(s.pid, signal.SIGTERM)
142
- except Exception:
143
- pass
144
- time.sleep(1)
145
- invalidate_cache()
146
- session = rc.session_name_for(s.cwd)
147
- return rc.run_in_tmux(session, s.sid[:8], tmux_resume_cmd(s, fork)) is not None
165
+ return _spawn_in_tmux(s, tmux_resume_cmd(s, fork), fork=fork) is not None
148
166
 
149
167
 
150
168
  def attach_target(s: Session) -> str | None:
@@ -172,21 +190,8 @@ def tmux_foreground_cmd(s: Session) -> str:
172
190
  def do_tmux_resume(s: Session) -> str | None:
173
191
  """Kill-if-takeover, spawn the resume window in the session's per-project
174
192
  tmux session, and return the exact tmux target to enter; None on failure.
175
-
176
- Mirrors `relaunch_in_tmux`'s kill handling (same `_resume_plan` should_kill
177
- + R10 refusal); the caller (cli) then calls `enter_window` on the target."""
178
- _, _, should_kill = _resume_plan(s)
179
- if should_kill and s.pid:
180
- if not proc.current_determinable():
181
- return None
182
- try:
183
- os.kill(s.pid, signal.SIGTERM)
184
- except Exception:
185
- pass
186
- time.sleep(1)
187
- invalidate_cache()
188
- session = rc.session_name_for(s.cwd)
189
- return rc.run_in_tmux(session, s.sid[:8], tmux_foreground_cmd(s))
193
+ The caller (cli) then calls `enter_window` on the target."""
194
+ return _spawn_in_tmux(s, tmux_foreground_cmd(s))
190
195
 
191
196
 
192
197
  def do_tmux_new(directory: str) -> str | None:
@@ -45,7 +45,7 @@ class TabView(Protocol):
45
45
  def apply_data(self) -> None: ...
46
46
  def keyhints(self) -> str: ...
47
47
  def handle_key(self, key: str) -> None: ...
48
- def deactivate(self) -> None: ... # tab switch-away: close transient FOOTER modes
48
+ def captures_text(self) -> bool: ... # True while a text mode owns EVERY key (incl. tab/q)
49
49
 
50
50
  # 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
51
51
  # ONE semantic set — views reference these names only (no per-tab duplicates;
@@ -146,10 +146,6 @@ class App:
146
146
  self.header.contents[1] = (tab_bar, self.header.options())
147
147
 
148
148
  def _switch_tab(self) -> None:
149
- # Close the outgoing tab's transient footer modes (e.g. the Sessions
150
- # filter Edit) — they turn invisible once the next tab's hints replace
151
- # the footer, but would keep eating keys after switching back.
152
- self.views[self._active].deactivate()
153
149
  self._active = (self._active + 1) % len(self.views)
154
150
  self.body.original_widget = self.views[self._active].widget
155
151
  self._update_tab_bar()
@@ -171,12 +167,19 @@ class App:
171
167
  elif key in ("n", "esc"):
172
168
  self._close_confirm()
173
169
  return
170
+ view = self.views[self._active]
171
+ if view.captures_text():
172
+ # A text mode (e.g. the Sessions filter Edit) owns EVERY key:
173
+ # consuming tab/q here would switch tabs / quit mid-typing (the q
174
+ # in "sql" used to exit csctl).
175
+ view.handle_key(key)
176
+ return
174
177
  if key == "tab":
175
178
  self._switch_tab()
176
179
  elif key == "q":
177
180
  self._exit()
178
181
  else:
179
- self.views[self._active].handle_key(key)
182
+ view.handle_key(key)
180
183
 
181
184
  def confirm(self, message: str, on_yes: Callable[[], None]) -> None:
182
185
  """Show a confirm modal over the active tab; run `on_yes` on Enter/y.
@@ -232,8 +235,13 @@ class App:
232
235
  self._exit(intent)
233
236
 
234
237
  def set_hints(self, hints: str) -> None:
235
- """Footer = shared prefix + the active tab's keyhints (D1 single source)."""
236
- self.footer_text.set_text(FOOTER_PREFIX + hints)
238
+ """Footer = shared prefix + the active tab's keyhints (D1 single source).
239
+
240
+ While the active view captures text, the prefix's Tab/q/r promises are
241
+ all false (every key goes to the text widget), so only the mode's own
242
+ hints are shown."""
243
+ prefix = "" if self.views[self._active].captures_text() else FOOTER_PREFIX
244
+ self.footer_text.set_text(prefix + hints)
237
245
 
238
246
  # --- the view-facing façade (views call these, never App internals) ---
239
247
 
@@ -242,14 +250,6 @@ class App:
242
250
  may ask about tab state (no peeking at `views`/`_active`)."""
243
251
  return self.views[self._active] is view
244
252
 
245
- def own_footer(self, widget: urwid.Widget) -> None:
246
- """Hand the footer to a view's transient widget (e.g. the filter Edit)."""
247
- self.frame.footer = widget
248
-
249
- def release_footer(self) -> None:
250
- """Give the footer back to the standard hints line."""
251
- self._restore_footer()
252
-
253
253
  def notify(self, msg: str, seconds: float = 3) -> None:
254
254
  # The newest notification owns the footer: cancel the previous restore
255
255
  # alarm so an older timer can't clear this message early.
@@ -138,10 +138,10 @@ def _cmd_rc(args: argparse.Namespace) -> None:
138
138
  def _cmd_prune(args: argparse.Namespace) -> None:
139
139
  from .data.cleanup import (
140
140
  cleanup_stats,
141
+ execute_orphan_removals,
142
+ execute_session_removals,
141
143
  list_orphan_dirs,
142
144
  prune_sessions,
143
- remove_orphan_dirs,
144
- remove_session,
145
145
  )
146
146
  from .data.sessions import scan
147
147
 
@@ -155,7 +155,10 @@ def _cmd_prune(args: argparse.Namespace) -> None:
155
155
  if not args.apply:
156
156
  print("Dry run. Add --apply to execute.")
157
157
  return
158
- count = remove_orphan_dirs(sessions)
158
+ # Deletes AT MOST the listed entries, revalidated against fresh
159
+ # protection data (删除 ⊆ 预览 — same executor as the TUI; `sessions`
160
+ # feeds the transcript tier of the protection set).
161
+ count = execute_orphan_removals(orphans, sessions=sessions)
159
162
  print(f"Swept {count} orphan dir(s).")
160
163
  return
161
164
 
@@ -174,9 +177,8 @@ def _cmd_prune(args: argparse.Namespace) -> None:
174
177
  print("Dry run. Add --apply to execute.")
175
178
  return
176
179
 
177
- for s in targets:
178
- remove_session(s)
179
- print(f"Pruned {len(targets)} session(s).")
180
+ count = execute_session_removals(targets)
181
+ print(f"Pruned {count} session(s).")
180
182
 
181
183
 
182
184
  def _cmd_prune_zombies(args: argparse.Namespace) -> None:
@@ -184,12 +186,12 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
184
186
 
185
187
  Reuses the already-gated `data/cleanup` helpers: `select_zombie_pids` keeps
186
188
  the current session's pid and any alive pid of a resumed multi-pid sid, and
187
- `remove_zombie_session_files` refuses without `/proc`. The dry-run preview is
189
+ `execute_zombie_removals` refuses without `/proc`. The dry-run preview is
188
190
  gated here too — off `/proc` every pid looks dead, so `current` can't be
189
191
  determined and we must not even claim the files are sweepable (R10).
190
192
  """
191
193
  from .data import liveness, proc
192
- from .data.cleanup import remove_zombie_session_files, select_zombie_pids
194
+ from .data.cleanup import execute_zombie_removals, select_zombie_pids
193
195
 
194
196
  if not proc.current_determinable():
195
197
  print("Refused: '/proc' unavailable — cannot determine the current session (R10).")
@@ -201,25 +203,25 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
201
203
  if not args.apply:
202
204
  print("Dry run. Add --apply to execute.")
203
205
  return
204
- count = remove_zombie_session_files(procs, cur)
206
+ count = execute_zombie_removals(zombies, session_procs=procs, cur=cur)
205
207
  print(f"Swept {count} zombie session file(s).")
206
208
 
207
209
 
208
210
  def _cmd_prune_aged(args: argparse.Namespace) -> None:
209
211
  """Strategy B age sweep of time/global-keyed dirs (R7.2) via the CLI.
210
212
 
211
- `remove_aged_entries` is mtime-only and session-agnostic, so (unlike the
212
- zombie sweep) it is not gated on `/proc`.
213
+ The age sweep is mtime-only and session-agnostic, so (unlike the zombie
214
+ sweep) it is not gated on `/proc`.
213
215
  """
214
216
  from .config import cfg
215
- from .data.cleanup import list_aged_entries, remove_aged_entries
217
+ from .data.cleanup import execute_aged_removals, list_aged_entries
216
218
 
217
219
  aged = list_aged_entries()
218
220
  print(f"Would sweep {len(aged)} aged entr(y/ies) older than {cfg.cleanup_age_days}d")
219
221
  if not args.apply:
220
222
  print("Dry run. Add --apply to execute.")
221
223
  return
222
- count = remove_aged_entries()
224
+ count = execute_aged_removals(aged)
223
225
  print(f"Swept {count} aged entr(y/ies).")
224
226
 
225
227
 
@@ -247,16 +249,15 @@ def _cmd_skill(args: argparse.Namespace) -> None:
247
249
 
248
250
 
249
251
  def _cmd_agents(args: argparse.Namespace) -> None:
250
- from .actions.agent_ops import job_host
252
+ from .data.liveness import enrich_jobs
251
253
  from .data.registry import read_agent_jobs
252
254
 
253
- jobs = read_agent_jobs(max_age=0.0)
255
+ jobs = enrich_jobs(read_agent_jobs(max_age=0.0))
254
256
  if not jobs:
255
257
  print("No background agents found.")
256
258
  return
257
259
  for job in jobs:
258
- _pid, alive = job_host(job)
259
- state = "live" if alive else (job.state or "settled")
260
+ state = "live" if job.host_alive else (job.state or "settled")
260
261
  tempo = job.tempo or "-"
261
262
  name = job.name or job.short
262
263
  print(f" {job.short} [{state}] tempo={tempo} {name} {job.cwd}")
@@ -266,25 +267,17 @@ def _cmd_env(args: argparse.Namespace) -> None:
266
267
  from .data import environments, rc
267
268
 
268
269
  # Scan RC servers so the env_* namespace is covered too (it has no state
269
- # file — only a running server references it).
270
- servers = rc.scan_servers()
271
- # CURRENT is alive-gated (R3/R6): a zombie session's stale bridge must not be
272
- # counted as bound. FILE-REFERENCED is the bridge-truthy membership set.
273
- observed = environments.observe_live(rc_servers=servers, max_age=0.0)
274
- file_referenced = environments.observe(rc_servers=servers, max_age=0.0)
275
- # Record every file-referenced env so a later run (after RC toggled off / a job
276
- # removed) reports it as an orphan = ledger − file-referenced (R6 persistence).
277
- environments.upsert(file_referenced)
278
- current = environments.current_envs(observed)
279
- orphans = environments.orphan_envs(file_referenced)
280
-
281
- print(f"Current bridge environments: {len(current)}")
282
- for e in current:
270
+ # file — only a running server references it). The whole observe → upsert →
271
+ # classify pipeline (and its ordering invariant) lives in reconcile.
272
+ recon = environments.reconcile(rc_servers=rc.scan_servers(), max_age=0.0)
273
+
274
+ print(f"Current bridge environments: {len(recon.current)}")
275
+ for e in recon.current:
283
276
  print(f" {e.env_id} sid={e.bound_sid or '-'}")
284
277
 
285
- print(f"Orphan environments (delete manually on claude.ai/code): {len(orphans)}")
286
- for row in environments.manual_delete_list(file_referenced):
287
- print(f" {row['env_id']} sid={row['bound_sid'] or '-'}")
278
+ print(f"Orphan environments (delete manually on claude.ai/code): {len(recon.orphans)}")
279
+ for e in recon.orphans:
280
+ print(f" {e.env_id} sid={e.bound_sid or '-'}")
288
281
 
289
282
  print(
290
283
  "Note: csctl cannot deregister cloud environments; "