cc-session-control 0.6.0__tar.gz → 0.6.2__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.0/src/cc_session_control.egg-info → cc_session_control-0.6.2}/PKG-INFO +3 -3
  2. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/README.md +2 -2
  3. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/agent_ops.py +2 -28
  5. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/session_ops.py +67 -0
  6. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/app.py +57 -20
  7. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/cli.py +10 -37
  8. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/cleanup.py +2 -14
  9. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/environments.py +62 -58
  10. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/liveness.py +21 -4
  11. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/rc.py +3 -10
  12. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/registry.py +2 -9
  13. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/sessions.py +3 -8
  14. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/snapshot.py +1 -4
  15. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/models.py +19 -0
  16. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/skill/SKILL.md +1 -1
  17. cc_session_control-0.6.2/src/cc_session_control/views/_base.py +130 -0
  18. cc_session_control-0.6.2/src/cc_session_control/views/_confirm.py +59 -0
  19. cc_session_control-0.6.2/src/cc_session_control/views/_keytable.py +51 -0
  20. cc_session_control-0.6.2/src/cc_session_control/views/_rows.py +41 -0
  21. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_session_row.py +2 -14
  22. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_sessions_cleanup.py +7 -9
  23. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/agents.py +79 -103
  24. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/rc.py +156 -111
  25. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/sessions.py +191 -202
  26. {cc_session_control-0.6.0 → cc_session_control-0.6.2/src/cc_session_control.egg-info}/PKG-INFO +3 -3
  27. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/SOURCES.txt +4 -1
  28. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_agent_ops.py +9 -4
  29. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_agents.py +53 -12
  30. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_app.py +49 -0
  31. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_liveness.py +32 -18
  32. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_rc.py +21 -0
  33. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_registry.py +14 -0
  34. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_sessions.py +4 -4
  35. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_skill_ops.py +12 -0
  36. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_views.py +164 -21
  37. cc_session_control-0.6.0/src/cc_session_control/data/agents.py +0 -12
  38. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/LICENSE +0 -0
  39. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/pyproject.toml +0 -0
  40. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/setup.cfg +0 -0
  41. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/__main__.py +0 -0
  42. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/__init__.py +0 -0
  43. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/resume_list.py +0 -0
  44. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/skill_ops.py +0 -0
  45. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/clipboard.py +0 -0
  46. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/config.py +0 -0
  47. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/__init__.py +0 -0
  48. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/proc.py +0 -0
  49. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/__init__.py +0 -0
  50. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_colspec.py +0 -0
  51. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  52. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  53. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/requires.txt +0 -0
  54. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/top_level.txt +0 -0
  55. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_cleanup.py +0 -0
  56. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_cli.py +0 -0
  57. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_data.py +0 -0
  58. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_environments.py +0 -0
  59. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_proc.py +0 -0
  60. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_resume_list.py +0 -0
  61. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_smoke.py +0 -0
  62. {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -29,9 +29,9 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
29
29
 
30
30
  ## Features
31
31
 
32
- - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
32
+ - **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
33
33
  - **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
34
- - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
34
+ - **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
35
35
 
36
36
  Built with [urwid](https://urwid.org/).
37
37
 
@@ -6,9 +6,9 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
6
6
 
7
7
  ## Features
8
8
 
9
- - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
9
+ - **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
10
10
  - **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
11
- - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
11
+ - **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
12
12
 
13
13
  Built with [urwid](https://urwid.org/).
14
14
 
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.2"
@@ -25,34 +25,11 @@ import os
25
25
  import shlex
26
26
  import signal
27
27
  import time
28
- from dataclasses import replace
29
28
 
30
29
  from ..config import cfg
31
30
  from ..data import cleanup, liveness, proc, rc, registry
32
31
  from ..models import AgentJob, Session
33
32
 
34
- # --- User-facing labels/help (Simplified Chinese, read by the Phase 7 view) ---
35
-
36
- TAKEOVER_LABEL = "接回"
37
-
38
- # Unified verb table (matches Sessions/RC): Enter/o=接回(primary), s=停止(kill a
39
- # live thing), d=删除, R=重启(respawn). `r`=刷新 lives in the App-level footer
40
- # prefix now, so it is NOT repeated here; separators are ` · ` like the other tabs.
41
- KEYHINTS = "Enter/o 接回 · s 停止 · d 删除 · w 查看 · R 重启"
42
-
43
- # Orphan-process risk (R4.5 red line): stop only kills the host pid joined from
44
- # the sessions registry, killing a --remote-control/bg worker does not always
45
- # fully reap it, and a live worker with no sessions file can't be located at all.
46
- HELP = (
47
- "后台 agent 生命周期:\n"
48
- " Enter/o 接回(拉回前台,复用 resume;接运行中的 agent 会先确认接管) w 查看 timeline(只读)\n"
49
- " R 重启(respawn) d 删除(仅已结束) s 停止(仅运行中,需确认) r 刷新\n"
50
- "停止/孤儿风险:停止只能杀经 sessions 文件 join 到的 host pid;"
51
- "杀 --remote-control/后台 agent 不一定彻底回收,可能残留孤儿进程,需手动确认;"
52
- "找不到运行中的后台 agent 的 host pid 时无法停止。"
53
- )
54
-
55
-
56
33
  # --- host-pid join (shared by stop_job, remove_job, and the view) -------------
57
34
 
58
35
  def job_host(job: AgentJob) -> tuple[int | None, bool]:
@@ -68,10 +45,7 @@ def job_host(job: AgentJob) -> tuple[int | None, bool]:
68
45
  Injects `/proc` liveness onto the registry rows, then defers to the single
69
46
  pure join `registry.host_pid_for_sid` (shared with `snapshot._enrich_jobs`).
70
47
  """
71
- procs = [
72
- replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
73
- for sp in registry.read_session_procs()
74
- ]
48
+ procs = liveness.live_session_procs()
75
49
  return registry.host_pid_for_sid(job.sid, procs)
76
50
 
77
51
 
@@ -151,7 +125,7 @@ def resume_takeover(job: AgentJob) -> Session:
151
125
 
152
126
  Bringing a bg session to the foreground is just a resume of its
153
127
  `resume_sid`, so this returns a Session the view feeds to the SAME
154
- `app.exit_with_resume` → `do_resume` pipeline used for foreground sessions —
128
+ `app.exit_with(ResumeIntent)` → `do_resume` pipeline used for foreground sessions —
155
129
  all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
156
130
  `pid`/`alive` come from the host join so a live worker is killed first
157
131
  (resume = takeover); `current` is computed so the launching session stays
@@ -6,6 +6,7 @@ import os
6
6
  import shlex
7
7
  import signal
8
8
  import time
9
+ from dataclasses import dataclass
9
10
 
10
11
  from .. import clipboard
11
12
  from ..data import proc, rc
@@ -202,6 +203,72 @@ def do_tmux_new(directory: str) -> str | None:
202
203
  return rc.run_in_tmux(rc.session_name_for(directory), "claude", cmd)
203
204
 
204
205
 
206
+ # --- exit intents (the payload crossing the exit-then-exec seam) ------------
207
+ #
208
+ # The TUI cannot run `claude` (or replace itself with tmux) inside the urwid
209
+ # loop, so a resume-family action exits the MainLoop carrying ONE of these
210
+ # intents and `cli._cmd_tui` calls `intent.run()` afterwards. Each intent owns
211
+ # its own finalizer + failure messages, so adding a variant means adding one
212
+ # class here + one view call — app.py and cli.py stay untouched.
213
+
214
+ class ExitIntent:
215
+ """What a view asks csctl to do AFTER the MainLoop exits."""
216
+
217
+ def run(self) -> None:
218
+ """Finalize outside the loop; may exec-replace the csctl process."""
219
+ raise NotImplementedError
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class ResumeIntent(ExitIntent):
224
+ """Enter/f: bare-terminal resume (execvp; takeover kill inside do_resume)."""
225
+
226
+ session: Session
227
+ fork: bool = False
228
+
229
+ def run(self) -> None:
230
+ do_resume(self.session, fork=self.fork)
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class AttachIntent(ExitIntent):
235
+ """`t` on a tmux-hosted session: enter its window in place (no kill)."""
236
+
237
+ target: str
238
+
239
+ def run(self) -> None:
240
+ if not enter_window(self.target):
241
+ print(f"Failed to enter tmux window {self.target} (is tmux running?)")
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class TmuxResumeIntent(ExitIntent):
246
+ """`t` on a dead / bare-terminal session: resume inside tmux, then enter."""
247
+
248
+ session: Session
249
+
250
+ def run(self) -> None:
251
+ target = do_tmux_resume(self.session)
252
+ if target is None:
253
+ print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
254
+ elif not enter_window(target):
255
+ print(f"Session resumed in tmux window {target}, but attaching failed.")
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class TmuxNewIntent(ExitIntent):
260
+ """项目-tab `t`: start a NEW claude in tmux, then enter (pure spawn)."""
261
+
262
+ directory: str
263
+
264
+ def run(self) -> None:
265
+ target = do_tmux_new(self.directory)
266
+ if target is None:
267
+ print("Failed to start a new session inside tmux (is tmux available?).")
268
+ elif not enter_window(target):
269
+ print(f"Session started in tmux window {target}, but attaching failed.")
270
+
271
+
205
272
  def enter_window(target: str) -> bool:
206
273
  """Bring `target` ("session:window") to the user's terminal foreground.
207
274
 
@@ -30,7 +30,8 @@ class TabView(Protocol):
30
30
  widgets; `apply_data()` runs on the main loop and swaps `_pending` into the
31
31
  walker. The `snapshot` is the shared per-cycle world (R11/D8); it is OPTIONAL
32
32
  — a view called with `None` self-fetches (back-compat / tests). Adding a tab
33
- means honoring every member below.
33
+ means honoring every member below; subclass `views/_base.py::ListTabView`
34
+ for the shared walker/overlay/footer plumbing instead of re-writing it.
34
35
  """
35
36
 
36
37
  widget: urwid.Widget
@@ -41,6 +42,7 @@ class TabView(Protocol):
41
42
  def apply_data(self) -> None: ...
42
43
  def keyhints(self) -> str: ...
43
44
  def handle_key(self, key: str) -> None: ...
45
+ def deactivate(self) -> None: ... # tab switch-away: close transient FOOTER modes
44
46
 
45
47
  # 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
46
48
  # ONE semantic set — views reference these names only (no per-tab duplicates;
@@ -87,9 +89,10 @@ def _make_screen() -> urwid.raw_display.Screen:
87
89
 
88
90
  class App:
89
91
  def __init__(self) -> None:
90
- self.result: tuple | None = None
92
+ self.result: object | None = None # a session_ops.ExitIntent, or None
91
93
  self._exiting = False
92
94
  self._alarm_handle: object | None = None
95
+ self._notify_alarm: object | None = None
93
96
  self._pipe_fd: int | None = None
94
97
  self._refreshing = False
95
98
  # When set, a confirm modal is up: `_input` routes Enter/y/n/esc here and
@@ -140,6 +143,10 @@ class App:
140
143
  self.header.contents[1] = (tab_bar, self.header.options())
141
144
 
142
145
  def _switch_tab(self) -> None:
146
+ # Close the outgoing tab's transient footer modes (e.g. the Sessions
147
+ # filter Edit) — they turn invisible once the next tab's hints replace
148
+ # the footer, but would keep eating keys after switching back.
149
+ self.views[self._active].deactivate()
143
150
  self._active = (self._active + 1) % len(self.views)
144
151
  self.body.original_widget = self.views[self._active].widget
145
152
  self._update_tab_bar()
@@ -181,10 +188,19 @@ class App:
181
188
  self._confirm_base = self.body.original_widget
182
189
  text = urwid.Text(f" {message}\n\n Enter / y 确认 n / Esc 取消")
183
190
  box = urwid.AttrMap(urwid.LineBox(urwid.Filler(text)), "notify")
191
+ # Size to the content: the old fixed 50%×7 clipped long labels and the
192
+ # n/Esc line on narrow terminals. Width floors at 46 cells (capped to
193
+ # the screen); height = wrapped text rows + the LineBox border.
194
+ try:
195
+ cols, rows = self._screen.get_cols_rows()
196
+ except Exception:
197
+ cols, rows = 80, 24
198
+ width = min(max(cols // 2, 46), cols)
199
+ height = min(text.rows((max(width - 2, 1),)) + 2, max(rows - 2, 3))
184
200
  self.body.original_widget = urwid.Overlay(
185
201
  box, self._confirm_base,
186
- align="center", width=("relative", 50),
187
- valign="middle", height=7,
202
+ align="center", width=width,
203
+ valign="middle", height=height,
188
204
  )
189
205
  self.footer_text.set_text(" Enter/y 确认 · n/Esc 取消")
190
206
 
@@ -195,37 +211,52 @@ class App:
195
211
  self._confirm_yes = None
196
212
  self.set_hints(self.views[self._active].keyhints())
197
213
 
198
- def _exit(self, result: tuple | None = None) -> None:
214
+ def _exit(self, result: object = None) -> None:
199
215
  self._exiting = True
200
216
  if self._alarm_handle:
201
217
  self.loop.remove_alarm(self._alarm_handle)
202
218
  self.result = result
203
219
  raise urwid.ExitMainLoop()
204
220
 
205
- def exit_with_resume(self, session: object, fork: bool = False) -> None:
206
- self._exit(("resume", session, fork))
207
-
208
- def exit_with_attach(self, target: str) -> None:
209
- """`t` on a session already living in a tmux window: enter it in place."""
210
- self._exit(("attach", target))
221
+ def exit_with(self, intent: object) -> None:
222
+ """Exit the MainLoop carrying a `session_ops.ExitIntent`.
211
223
 
212
- def exit_with_tmux_resume(self, session: object) -> None:
213
- """`t` on a dead / bare-terminal session: resume it inside tmux, then enter."""
214
- self._exit(("tmux_resume", session))
215
-
216
- def exit_with_tmux_new(self, directory: str) -> None:
217
- """`t` on a project row: start a NEW claude session in tmux, then enter."""
218
- self._exit(("tmux_new", directory))
224
+ Views construct the intent (they know the verb); `cli._cmd_tui` calls
225
+ `intent.run()` after the loop ends resume must exec OUTSIDE urwid, so
226
+ the finalizer cannot run here. App stays variant-agnostic: adding a
227
+ resume variant touches only the intent class + the view key handler.
228
+ """
229
+ self._exit(intent)
219
230
 
220
231
  def set_hints(self, hints: str) -> None:
221
232
  """Footer = shared prefix + the active tab's keyhints (D1 single source)."""
222
233
  self.footer_text.set_text(FOOTER_PREFIX + hints)
223
234
 
235
+ # --- the view-facing façade (views call these, never App internals) ---
236
+
237
+ def is_active(self, view: TabView) -> bool:
238
+ """Whether `view` is the tab currently shown — the one question views
239
+ may ask about tab state (no peeking at `views`/`_active`)."""
240
+ return self.views[self._active] is view
241
+
242
+ def own_footer(self, widget: urwid.Widget) -> None:
243
+ """Hand the footer to a view's transient widget (e.g. the filter Edit)."""
244
+ self.frame.footer = widget
245
+
246
+ def release_footer(self) -> None:
247
+ """Give the footer back to the standard hints line."""
248
+ self._restore_footer()
249
+
224
250
  def notify(self, msg: str, seconds: float = 3) -> None:
251
+ # The newest notification owns the footer: cancel the previous restore
252
+ # alarm so an older timer can't clear this message early.
253
+ if self._notify_alarm is not None:
254
+ self.loop.remove_alarm(self._notify_alarm)
225
255
  self.frame.footer = urwid.AttrMap(urwid.Text(f" {msg}"), "notify")
226
- self.loop.set_alarm_in(seconds, lambda *_: self._restore_footer())
256
+ self._notify_alarm = self.loop.set_alarm_in(seconds, lambda *_: self._restore_footer())
227
257
 
228
258
  def _restore_footer(self) -> None:
259
+ self._notify_alarm = None
229
260
  self.frame.footer = self.footer
230
261
 
231
262
  def _run_fetch_cycle(self) -> None:
@@ -245,6 +276,12 @@ class App:
245
276
  for v in self.views:
246
277
  v.fetch_pending(snapshot)
247
278
 
279
+ def refresh_with_notice(self) -> None:
280
+ """Refresh + the standard footer notice — the single body of the
281
+ footer-prefix promise `r 刷新` (list AND modal modes, every tab)."""
282
+ self.trigger_async_refresh()
283
+ self.notify("刷新中…")
284
+
248
285
  def trigger_async_refresh(self) -> None:
249
286
  if self._refreshing or self._exiting:
250
287
  return
@@ -275,7 +312,7 @@ class App:
275
312
  view.apply_data()
276
313
  return True
277
314
 
278
- def run(self) -> tuple | None:
315
+ def run(self) -> object | None:
279
316
  self._pipe_fd = self.loop.watch_pipe(self._on_pipe)
280
317
  self.views[self._active].load()
281
318
  self.set_hints(self.views[self._active].keyhints())
@@ -87,7 +87,8 @@ def _cmd_rc(args: argparse.Namespace) -> None:
87
87
  for p in projects:
88
88
  icon = {"running": "[running]", "dead": "[dead ]", "stopped": "[stopped]"}.get(p.status, p.status)
89
89
  auto = "auto" if p.auto_start else " "
90
- print(f" {icon} {auto} {p.name}")
90
+ missing = "" if p.dir_exists else " (directory missing)"
91
+ print(f" {icon} {auto} {p.name}{missing}")
91
92
 
92
93
  elif sub == "add":
93
94
  proj = args.project
@@ -187,18 +188,13 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
187
188
  gated here too — off `/proc` every pid looks dead, so `current` can't be
188
189
  determined and we must not even claim the files are sweepable (R10).
189
190
  """
190
- from dataclasses import replace
191
-
192
- from .data import proc, registry
191
+ from .data import liveness, proc
193
192
  from .data.cleanup import remove_zombie_session_files, select_zombie_pids
194
193
 
195
194
  if not proc.current_determinable():
196
195
  print("Refused: '/proc' unavailable — cannot determine the current session (R10).")
197
196
  return
198
- procs = [
199
- replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
200
- for sp in registry.read_session_procs(max_age=0.0)
201
- ]
197
+ procs = liveness.live_session_procs(max_age=0.0)
202
198
  cur = proc.ancestor_pids()
203
199
  zombies = select_zombie_pids(procs, cur)
204
200
  print(f"Would sweep {len(zombies)} zombie session file(s)")
@@ -298,38 +294,15 @@ def _cmd_env(args: argparse.Namespace) -> None:
298
294
 
299
295
 
300
296
  def _cmd_tui(args: argparse.Namespace) -> None:
301
- from .actions.session_ops import do_resume, do_tmux_new, do_tmux_resume, enter_window
297
+ from .actions.session_ops import ExitIntent
302
298
  from .app import App
303
299
 
304
- app = App()
305
- result = app.run()
300
+ result = App().run()
306
301
 
307
- if not result or not isinstance(result, tuple):
308
- return
309
- if result[0] == "resume":
310
- _, session, fork = result
311
- do_resume(session, fork=fork)
312
- elif result[0] == "attach":
313
- # `t` on a tmux-hosted session: enter its window (exec replaces csctl
314
- # outside tmux; switch-client + normal exit inside).
315
- if not enter_window(result[1]):
316
- print(f"Failed to enter tmux window {result[1]} (is tmux running?)")
317
- elif result[0] == "tmux_resume":
318
- # `t` on a dead / bare-terminal session: spawn the resume window, then
319
- # enter it. Kill semantics (takeover) are handled inside do_tmux_resume.
320
- target = do_tmux_resume(result[1])
321
- if target is None:
322
- print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
323
- elif not enter_window(target):
324
- print(f"Session resumed in tmux window {target}, but attaching failed.")
325
- elif result[0] == "tmux_new":
326
- # `t` on a 项目-tab project row: start a NEW claude session in tmux,
327
- # then enter it (nothing is killed — pure spawn).
328
- target = do_tmux_new(result[1])
329
- if target is None:
330
- print("Failed to start a new session inside tmux (is tmux available?).")
331
- elif not enter_window(target):
332
- print(f"Session started in tmux window {target}, but attaching failed.")
302
+ # The intent finalizes itself outside the urwid loop (it may exec-replace
303
+ # csctl — resume/attach) and prints its own failure messages.
304
+ if isinstance(result, ExitIntent):
305
+ result.run()
333
306
 
334
307
 
335
308
  def main() -> None:
@@ -37,7 +37,6 @@ from __future__ import annotations
37
37
  import os
38
38
  import shutil
39
39
  import time
40
- from dataclasses import replace
41
40
 
42
41
  from ..config import cfg
43
42
  from ..models import AgentJob, Session, SessionProc
@@ -100,17 +99,6 @@ def _remove_path(path: str) -> bool:
100
99
 
101
100
  # --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
102
101
 
103
- def _live_session_procs(max_age: float = 5.0) -> list[SessionProc]:
104
- """Registry session files with `/proc` liveness injected (swallow-error)."""
105
- try:
106
- return [
107
- replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
108
- for sp in registry.read_session_procs(max_age=max_age)
109
- ]
110
- except Exception:
111
- return []
112
-
113
-
114
102
  def known_sids(
115
103
  sessions: list[Session],
116
104
  session_procs: list[SessionProc],
@@ -163,7 +151,7 @@ def _gather_known(
163
151
  self-read swallows its own errors → safe empties.
164
152
  """
165
153
  if session_procs is None:
166
- session_procs = _live_session_procs()
154
+ session_procs = liveness.live_session_procs()
167
155
  if agent_jobs is None:
168
156
  try:
169
157
  agent_jobs = registry.read_agent_jobs()
@@ -353,7 +341,7 @@ def remove_session(s: Session) -> bool:
353
341
  if _remove_path(p):
354
342
  removed = True
355
343
  # M3: never delete a LIVE agent worker's jobs/<short> dir.
356
- _, host_alive = registry.host_pid_for_sid(s.sid, _live_session_procs())
344
+ _, host_alive = registry.host_pid_for_sid(s.sid, liveness.live_session_procs())
357
345
  if not host_alive and _remove_path(_jobs_path(s.sid)):
358
346
  removed = True
359
347
  return removed
@@ -48,12 +48,18 @@ import contextlib
48
48
  import json
49
49
  import os
50
50
  from collections.abc import Iterator
51
- from dataclasses import replace
52
51
  from typing import Any
53
52
 
54
53
  from ..config import cfg
55
- from ..models import AgentJob, BridgeEnv, EnvRecord, RCServer, SessionProc
56
- from . import proc, registry
54
+ from ..models import (
55
+ AgentJob,
56
+ BridgeEnv,
57
+ EnvRecord,
58
+ RCServer,
59
+ SessionProc,
60
+ split_env_id,
61
+ )
62
+ from . import liveness, registry
57
63
 
58
64
  try: # POSIX advisory locking; absent on Windows → degrade to no lock.
59
65
  import fcntl
@@ -65,17 +71,51 @@ _RETENTION_SECONDS = 90 * 86400
65
71
  _MAX_ENTRIES = 500
66
72
 
67
73
 
68
- # --- bridge id parsing -----------------------------------------------------
74
+ # --- observation builder (reads registry + liveness, never rc) -------------
69
75
 
70
- def _split_bridge(bridge: str) -> tuple[str, str]:
71
- """`cse_abc` -> (`cse`, `abc`); the suffix is the namespace-local env id."""
72
- prefix, sep, suffix = bridge.partition("_")
73
- if not sep:
74
- return "", ""
75
- return prefix, suffix
76
+ def _collect(
77
+ session_procs: list[SessionProc],
78
+ agent_jobs: list[AgentJob],
79
+ rc_servers: list[RCServer] | None,
80
+ *,
81
+ alive_gated: bool,
82
+ ) -> list[EnvRecord]:
83
+ """The ONE observation walk over the three env sources (R6).
76
84
 
85
+ `alive_gated=False` → the FILE-REFERENCED membership set (any env a file
86
+ references, alive or zombie). `alive_gated=True` → the CURRENT set, each
87
+ source gated by its own liveness rule: `session_*` by the single
88
+ `liveness.is_rc_exposed` predicate, `cse_*` by a host-alive job or a
89
+ proc-alive session sharing the job sid, `env_*` by a running RC server.
90
+ """
91
+ alive_sids = {sp.sid for sp in session_procs if sp.proc_alive}
92
+ records: list[EnvRecord] = []
93
+ for sp in session_procs:
94
+ if not sp.bridge:
95
+ continue
96
+ if alive_gated and not liveness.is_rc_exposed(sp.bridge, sp.proc_alive):
97
+ continue
98
+ prefix, key = split_env_id(sp.bridge)
99
+ if prefix and key:
100
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
101
+ for job in agent_jobs:
102
+ if not job.env_suffix:
103
+ continue
104
+ if alive_gated and not (job.host_alive or job.sid in alive_sids):
105
+ continue
106
+ records.append(
107
+ EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
108
+ )
109
+ for srv in rc_servers or []:
110
+ if not srv.env_id:
111
+ continue
112
+ if alive_gated and srv.status != "running":
113
+ continue
114
+ prefix, key = split_env_id(srv.env_id)
115
+ if prefix and key:
116
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
117
+ return records
77
118
 
78
- # --- observation builder (reads registry only, never rc) -------------------
79
119
 
80
120
  def observe(
81
121
  session_procs: list[SessionProc] | None = None,
@@ -102,24 +142,7 @@ def observe(
102
142
  session_procs = registry.read_session_procs(max_age=max_age)
103
143
  if agent_jobs is None:
104
144
  agent_jobs = registry.read_agent_jobs(max_age=max_age)
105
- records: list[EnvRecord] = []
106
- for sp in session_procs:
107
- if not sp.bridge:
108
- continue
109
- prefix, key = _split_bridge(sp.bridge)
110
- if prefix and key:
111
- records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
112
- for job in agent_jobs:
113
- if job.env_suffix:
114
- records.append(
115
- EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
116
- )
117
- for srv in rc_servers or []:
118
- if srv.env_id:
119
- prefix, sep, key = srv.env_id.partition("_")
120
- if sep and prefix and key:
121
- records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
122
- return records
145
+ return _collect(session_procs, agent_jobs, rc_servers, alive_gated=False)
123
146
  except Exception:
124
147
  return []
125
148
 
@@ -132,13 +155,14 @@ def observe_live(
132
155
  ) -> list[EnvRecord]:
133
156
  """Alive-gated "currently exposed" bridge envs (R3/R6) — the CURRENT set.
134
157
 
135
- Where `observe()` is a bridge-truthy passive collector, this lifts the
136
- `_is_rc_exposed` predicate to the env ledger: a bridge is CURRENT only when
137
- its owner is alive — `session_*` gated by proc-alive, `cse_*` by a proc-alive
138
- session sharing the job sid (host-alive), `env_*` by a running RC server. So a
139
- zombie session's stale `bridgeSessionId` is NOT reported as current it falls
140
- through to `orphan_envs` (a manual-delete candidate) instead of overstating
141
- the bound count.
158
+ Where `observe()` is a bridge-truthy passive collector, this applies the
159
+ single `liveness.is_rc_exposed` predicate (and the per-source gates in
160
+ `_collect`): a bridge is CURRENT only when its owner is alive — `session_*`
161
+ gated by proc-alive, `cse_*` by a proc-alive session sharing the job sid
162
+ (host-alive), `env_*` by a running RC server. So a zombie session's stale
163
+ `bridgeSessionId` is NOT reported as current — it falls through to
164
+ `orphan_envs` (a manual-delete candidate) instead of overstating the bound
165
+ count.
142
166
 
143
167
  Pure when `session_procs`/`agent_jobs` are supplied (the snapshot path passes
144
168
  its already-liveness-resolved data — DI for tests); reads the registry +
@@ -147,30 +171,10 @@ def observe_live(
147
171
  """
148
172
  try:
149
173
  if session_procs is None:
150
- session_procs = [
151
- replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
152
- for sp in registry.read_session_procs(max_age=max_age)
153
- ]
174
+ session_procs = liveness.live_session_procs(max_age=max_age)
154
175
  if agent_jobs is None:
155
176
  agent_jobs = registry.read_agent_jobs(max_age=max_age)
156
- alive_sids = {sp.sid for sp in session_procs if sp.proc_alive}
157
- records: list[EnvRecord] = []
158
- for sp in session_procs:
159
- if sp.bridge and sp.proc_alive:
160
- prefix, key = _split_bridge(sp.bridge)
161
- if prefix and key:
162
- records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
163
- for job in agent_jobs:
164
- if job.env_suffix and (job.host_alive or job.sid in alive_sids):
165
- records.append(
166
- EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
167
- )
168
- for srv in rc_servers or []:
169
- if srv.env_id and srv.status == "running":
170
- prefix, sep, key = srv.env_id.partition("_")
171
- if sep and prefix and key:
172
- records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
173
- return records
177
+ return _collect(session_procs, agent_jobs, rc_servers, alive_gated=True)
174
178
  except Exception:
175
179
  return []
176
180
 
@@ -1,7 +1,6 @@
1
1
  """Session liveness — the single authority.
2
2
 
3
- Owns the ONE module-global cache for `claude agents --json` (mirrored by the
4
- `agents.py` re-export shim so terminate's invalidate and scan's read share it).
3
+ Owns the ONE module-global cache for `claude agents --json`.
5
4
  `live_index` is a pure merge of already-fetched liveness inputs (registry
6
5
  session files with injected proc liveness + `claude agents --json`).
7
6
  """
@@ -12,9 +11,10 @@ import json
12
11
  import subprocess
13
12
  import time
14
13
  from collections.abc import Callable
14
+ from dataclasses import replace
15
15
 
16
16
  from ..models import LiveInfo, SessionProc
17
- from . import proc
17
+ from . import proc, registry
18
18
 
19
19
  _cache: dict[str, int | None] | None = None
20
20
  _cache_time: float = 0.0
@@ -69,6 +69,23 @@ def invalidate_cache() -> None:
69
69
  _cache = None
70
70
 
71
71
 
72
+ def live_session_procs(max_age: float = 5.0) -> list[SessionProc]:
73
+ """Registry session files with `/proc` liveness injected — THE assembly point.
74
+
75
+ `registry.read_session_procs` deliberately leaves `proc_alive=False` (pure
76
+ parse, no `/proc`); a `SessionProc.proc_alive` is only trustworthy after
77
+ this injection. Every consumer must come through here rather than re-inline
78
+ the `replace(sp, proc_alive=pid_alive(...))` idiom. Swallows errors → [].
79
+ """
80
+ try:
81
+ return [
82
+ replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
83
+ for sp in registry.read_session_procs(max_age=max_age)
84
+ ]
85
+ except Exception:
86
+ return []
87
+
88
+
72
89
  def _source_of(entrypoint: str, kind: str) -> str:
73
90
  """Coarse source bucket from the registry entrypoint/kind (D9)."""
74
91
  if kind == "bg":
@@ -80,7 +97,7 @@ def _source_of(entrypoint: str, kind: str) -> str:
80
97
  return "cli"
81
98
 
82
99
 
83
- def _is_rc_exposed(bridge: str | None, pid_alive: bool) -> bool:
100
+ def is_rc_exposed(bridge: str | None, pid_alive: bool) -> bool:
84
101
  """Whether session remote control is CURRENTLY exposed (pure predicate).
85
102
 
86
103
  Exposed iff the bridge id is a truthy string AND the owning process is still