cc-session-control 0.6.1__tar.gz → 0.6.3__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.1/src/cc_session_control.egg-info → cc_session_control-0.6.3}/PKG-INFO +1 -1
  2. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/__init__.py +1 -1
  3. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/actions/agent_ops.py +2 -28
  4. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/actions/session_ops.py +67 -0
  5. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/app.py +32 -18
  6. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/cli.py +10 -37
  7. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/cleanup.py +2 -14
  8. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/environments.py +62 -58
  9. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/liveness.py +21 -4
  10. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/rc.py +3 -10
  11. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/registry.py +2 -9
  12. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/sessions.py +3 -8
  13. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/snapshot.py +1 -4
  14. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/models.py +19 -0
  15. cc_session_control-0.6.3/src/cc_session_control/views/_base.py +144 -0
  16. cc_session_control-0.6.3/src/cc_session_control/views/_confirm.py +59 -0
  17. cc_session_control-0.6.3/src/cc_session_control/views/_keytable.py +51 -0
  18. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/_sessions_cleanup.py +1 -4
  19. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/agents.py +77 -109
  20. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/rc.py +150 -130
  21. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/sessions.py +180 -214
  22. {cc_session_control-0.6.1 → cc_session_control-0.6.3/src/cc_session_control.egg-info}/PKG-INFO +1 -1
  23. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control.egg-info/SOURCES.txt +3 -1
  24. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_agent_ops.py +9 -4
  25. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_agents.py +21 -11
  26. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_liveness.py +32 -18
  27. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_rc.py +21 -0
  28. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_registry.py +14 -0
  29. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_sessions.py +4 -4
  30. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_views.py +74 -21
  31. cc_session_control-0.6.1/src/cc_session_control/data/agents.py +0 -12
  32. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/LICENSE +0 -0
  33. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/README.md +0 -0
  34. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/pyproject.toml +0 -0
  35. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/setup.cfg +0 -0
  36. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/__main__.py +0 -0
  37. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/actions/__init__.py +0 -0
  38. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/actions/resume_list.py +0 -0
  39. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/actions/skill_ops.py +0 -0
  40. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/clipboard.py +0 -0
  41. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/config.py +0 -0
  42. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/__init__.py +0 -0
  43. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/data/proc.py +0 -0
  44. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/skill/SKILL.md +0 -0
  45. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/__init__.py +0 -0
  46. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/_colspec.py +0 -0
  47. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/_rows.py +0 -0
  48. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control/views/_session_row.py +0 -0
  49. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  50. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  51. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control.egg-info/requires.txt +0 -0
  52. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/src/cc_session_control.egg-info/top_level.txt +0 -0
  53. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_app.py +0 -0
  54. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_cleanup.py +0 -0
  55. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_cli.py +0 -0
  56. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_data.py +0 -0
  57. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_environments.py +0 -0
  58. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_proc.py +0 -0
  59. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_resume_list.py +0 -0
  60. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_skill_ops.py +0 -0
  61. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/tests/test_smoke.py +0 -0
  62. {cc_session_control-0.6.1 → cc_session_control-0.6.3}/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.1
3
+ Version: 0.6.3
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.1"
3
+ __version__ = "0.6.3"
@@ -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
 
@@ -6,7 +6,7 @@ import curses
6
6
  import os
7
7
  import threading
8
8
  from collections.abc import Callable
9
- from typing import Protocol, runtime_checkable
9
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
10
10
 
11
11
  import urwid
12
12
 
@@ -16,6 +16,9 @@ from .views.agents import AgentsView
16
16
  from .views.rc import RCView
17
17
  from .views.sessions import SessionsView
18
18
 
19
+ if TYPE_CHECKING:
20
+ from .actions.session_ops import ExitIntent
21
+
19
22
  # D7/R10: shown across all tabs when `/proc` is unavailable (e.g. macOS), where
20
23
  # the "current" session can't be determined and destructive ops are refused.
21
24
  _DEGRADED_BANNER = "⚠ liveness 降级(无 /proc):terminate/delete/cleanup 已受限"
@@ -30,7 +33,8 @@ class TabView(Protocol):
30
33
  widgets; `apply_data()` runs on the main loop and swaps `_pending` into the
31
34
  walker. The `snapshot` is the shared per-cycle world (R11/D8); it is OPTIONAL
32
35
  — a view called with `None` self-fetches (back-compat / tests). Adding a tab
33
- means honoring every member below.
36
+ means honoring every member below; subclass `views/_base.py::ListTabView`
37
+ for the shared walker/overlay/footer plumbing instead of re-writing it.
34
38
  """
35
39
 
36
40
  widget: urwid.Widget
@@ -88,7 +92,7 @@ def _make_screen() -> urwid.raw_display.Screen:
88
92
 
89
93
  class App:
90
94
  def __init__(self) -> None:
91
- self.result: tuple | None = None
95
+ self.result: ExitIntent | None = None
92
96
  self._exiting = False
93
97
  self._alarm_handle: object | None = None
94
98
  self._notify_alarm: object | None = None
@@ -210,32 +214,42 @@ class App:
210
214
  self._confirm_yes = None
211
215
  self.set_hints(self.views[self._active].keyhints())
212
216
 
213
- def _exit(self, result: tuple | None = None) -> None:
217
+ def _exit(self, result: ExitIntent | None = None) -> None:
214
218
  self._exiting = True
215
219
  if self._alarm_handle:
216
220
  self.loop.remove_alarm(self._alarm_handle)
217
221
  self.result = result
218
222
  raise urwid.ExitMainLoop()
219
223
 
220
- def exit_with_resume(self, session: object, fork: bool = False) -> None:
221
- self._exit(("resume", session, fork))
222
-
223
- def exit_with_attach(self, target: str) -> None:
224
- """`t` on a session already living in a tmux window: enter it in place."""
225
- self._exit(("attach", target))
224
+ def exit_with(self, intent: ExitIntent) -> None:
225
+ """Exit the MainLoop carrying a `session_ops.ExitIntent`.
226
226
 
227
- def exit_with_tmux_resume(self, session: object) -> None:
228
- """`t` on a dead / bare-terminal session: resume it inside tmux, then enter."""
229
- self._exit(("tmux_resume", session))
230
-
231
- def exit_with_tmux_new(self, directory: str) -> None:
232
- """`t` on a project row: start a NEW claude session in tmux, then enter."""
233
- self._exit(("tmux_new", directory))
227
+ Views construct the intent (they know the verb); `cli._cmd_tui` calls
228
+ `intent.run()` after the loop ends resume must exec OUTSIDE urwid, so
229
+ the finalizer cannot run here. App stays variant-agnostic: adding a
230
+ resume variant touches only the intent class + the view key handler.
231
+ """
232
+ self._exit(intent)
234
233
 
235
234
  def set_hints(self, hints: str) -> None:
236
235
  """Footer = shared prefix + the active tab's keyhints (D1 single source)."""
237
236
  self.footer_text.set_text(FOOTER_PREFIX + hints)
238
237
 
238
+ # --- the view-facing façade (views call these, never App internals) ---
239
+
240
+ def is_active(self, view: TabView) -> bool:
241
+ """Whether `view` is the tab currently shown — the one question views
242
+ may ask about tab state (no peeking at `views`/`_active`)."""
243
+ return self.views[self._active] is view
244
+
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
+
239
253
  def notify(self, msg: str, seconds: float = 3) -> None:
240
254
  # The newest notification owns the footer: cancel the previous restore
241
255
  # alarm so an older timer can't clear this message early.
@@ -301,7 +315,7 @@ class App:
301
315
  view.apply_data()
302
316
  return True
303
317
 
304
- def run(self) -> tuple | None:
318
+ def run(self) -> ExitIntent | None:
305
319
  self._pipe_fd = self.loop.watch_pipe(self._on_pipe)
306
320
  self.views[self._active].load()
307
321
  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
@@ -10,7 +10,7 @@ import subprocess
10
10
  import time
11
11
 
12
12
  from ..config import cfg
13
- from ..models import EnvRecord, RCProject, RCServer
13
+ from ..models import EnvRecord, RCProject, RCServer, split_env_id
14
14
  from . import environments, proc
15
15
 
16
16
  # Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
@@ -362,18 +362,11 @@ def scan() -> list[RCProject]:
362
362
  auto_start=name in enabled,
363
363
  rc_at_startup=_read_rc_at_startup(directory),
364
364
  spawn_mode=_read_spawn_mode(name),
365
+ dir_exists=os.path.isdir(directory),
365
366
  ))
366
367
  return result
367
368
 
368
369
 
369
- def _split_env(env_id: str) -> tuple[str, str]:
370
- """`env_abc` -> (`env`, `abc`); ("", "") when not a namespaced id."""
371
- prefix, sep, suffix = env_id.partition("_")
372
- if not sep or not prefix or not suffix:
373
- return "", ""
374
- return prefix, suffix
375
-
376
-
377
370
  def _capture_env_id(target: str) -> str:
378
371
  """Grep an `env_*` cloud id from a managed server's pane output, or "".
379
372
 
@@ -416,7 +409,7 @@ def scan_servers() -> list[RCServer]:
416
409
  found = by_pid.get(pid)
417
410
  env_id = _capture_env_id(target)
418
411
  if env_id:
419
- prefix, key = _split_env(env_id)
412
+ prefix, key = split_env_id(env_id)
420
413
  if prefix and key:
421
414
  env_records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
422
415
  servers.append(RCServer(
@@ -18,7 +18,7 @@ import os
18
18
  import time
19
19
 
20
20
  from ..config import cfg
21
- from ..models import AgentJob, SessionProc
21
+ from ..models import AgentJob, SessionProc, split_env_id
22
22
 
23
23
  _sessions_cache: list[SessionProc] | None = None
24
24
  _sessions_time: float = 0.0
@@ -33,13 +33,6 @@ def invalidate_cache() -> None:
33
33
  _jobs_cache = None
34
34
 
35
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
36
  def _parse_session_proc(path: str) -> SessionProc | None:
44
37
  try:
45
38
  with open(path, errors="ignore") as fh:
@@ -106,7 +99,7 @@ def _parse_agent_job(state_path: str) -> AgentJob | None:
106
99
  tempo=d.get("tempo", "") or "",
107
100
  cwd=d.get("cwd", "") or "",
108
101
  name=d.get("name", "") or "",
109
- env_suffix=_suffix(d.get("bridgeSessionId")),
102
+ env_suffix=split_env_id(d.get("bridgeSessionId"))[1],
110
103
  respawn_flags=[str(x) for x in flags],
111
104
  )
112
105