cc-session-control 0.6.4__tar.gz → 0.6.5__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.4/src/cc_session_control.egg-info → cc_session_control-0.6.5}/PKG-INFO +1 -1
  2. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/__init__.py +1 -1
  3. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/agent_ops.py +4 -12
  4. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/session_ops.py +6 -6
  5. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/cli.py +15 -5
  6. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/cleanup.py +59 -71
  7. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/liveness.py +24 -0
  8. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/rc.py +15 -182
  9. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/snapshot.py +7 -30
  10. cc_session_control-0.6.5/src/cc_session_control/data/tmux.py +197 -0
  11. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/sessions.py +1 -1
  12. {cc_session_control-0.6.4 → cc_session_control-0.6.5/src/cc_session_control.egg-info}/PKG-INFO +1 -1
  13. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
  14. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_agent_ops.py +1 -1
  15. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_cleanup.py +2 -2
  16. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_cli.py +4 -3
  17. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_data.py +29 -66
  18. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_snapshot.py +5 -5
  19. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/LICENSE +0 -0
  20. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/README.md +0 -0
  21. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/pyproject.toml +0 -0
  22. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/setup.cfg +0 -0
  23. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/__main__.py +0 -0
  24. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/__init__.py +0 -0
  25. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/resume_list.py +0 -0
  26. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/skill_ops.py +0 -0
  27. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/app.py +0 -0
  28. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/clipboard.py +0 -0
  29. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/config.py +0 -0
  30. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/__init__.py +0 -0
  31. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/environments.py +0 -0
  32. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/proc.py +0 -0
  33. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/registry.py +0 -0
  34. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/sessions.py +0 -0
  35. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/models.py +0 -0
  36. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/skill/SKILL.md +0 -0
  37. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/__init__.py +0 -0
  38. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_base.py +0 -0
  39. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_colspec.py +0 -0
  40. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_confirm.py +0 -0
  41. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_keytable.py +0 -0
  42. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_rows.py +0 -0
  43. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_session_row.py +0 -0
  44. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
  45. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/agents.py +0 -0
  46. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/rc.py +0 -0
  47. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  48. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  49. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/requires.txt +0 -0
  50. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/top_level.txt +0 -0
  51. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_agents.py +0 -0
  52. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_app.py +0 -0
  53. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_environments.py +0 -0
  54. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_liveness.py +0 -0
  55. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_proc.py +0 -0
  56. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_rc.py +0 -0
  57. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_registry.py +0 -0
  58. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_resume_list.py +0 -0
  59. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_sessions.py +0 -0
  60. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_skill_ops.py +0 -0
  61. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_smoke.py +0 -0
  62. {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.6.4
3
+ Version: 0.6.5
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.4"
3
+ __version__ = "0.6.5"
@@ -25,7 +25,7 @@ import os
25
25
  import shlex
26
26
 
27
27
  from ..config import cfg
28
- from ..data import cleanup, liveness, proc, rc, registry
28
+ from ..data import cleanup, liveness, proc, registry, tmux
29
29
  from ..models import AgentJob, Session
30
30
  from . import session_ops
31
31
 
@@ -71,12 +71,12 @@ def respawn(job: AgentJob) -> str:
71
71
  """Relaunch a background agent in tmux; returns the exact command string.
72
72
 
73
73
  Runs `respawn_cmd(job)` in the job's per-project tmux session
74
- (`rc.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
74
+ (`tmux.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
75
75
  os.exec/replace the csctl process. The returned string also feeds the
76
76
  clipboard `y`-style key.
77
77
  """
78
78
  cmd = respawn_cmd(job)
79
- rc.run_in_tmux(rc.session_name_for(job.cwd), _job_window(job), cmd)
79
+ tmux.run_in_tmux(tmux.session_name_for(job.cwd), _job_window(job), cmd)
80
80
  return cmd
81
81
 
82
82
 
@@ -94,15 +94,7 @@ def remove_job(job: AgentJob) -> bool:
94
94
  _, alive = job_host(job)
95
95
  if alive:
96
96
  return False
97
- job_dir = os.path.join(str(cfg.jobs_dir), job.short)
98
- removed = cleanup._remove_path(job_dir)
99
- # Reuse cleanup's artifact-path helper / remover: it returns the sid-keyed
100
- # dirs (session-env/file-history/tasks/uploads) plus jobs/<sid[:8]> (which
101
- # usually equals job_dir — a second remove is a harmless no-op), so the job's
102
- # session leaves no orphan artifacts behind.
103
- for path in cleanup._session_artifact_paths(job.sid):
104
- cleanup._remove_path(path)
105
- return removed
97
+ return cleanup.remove_agent_artifacts(job.short, job.sid)
106
98
 
107
99
 
108
100
  # --- watch (read-only) --------------------------------------------------------
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from typing import Literal
11
11
 
12
12
  from .. import clipboard
13
- from ..data import proc, rc
13
+ from ..data import proc, tmux
14
14
  from ..data.liveness import invalidate_cache
15
15
  from ..models import Session
16
16
 
@@ -151,7 +151,7 @@ def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
151
151
  _, _, should_kill = _resume_plan(s, fork)
152
152
  if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
153
153
  return None
154
- return rc.run_in_tmux(rc.session_name_for(s.cwd), s.sid[:8], cmd)
154
+ return tmux.run_in_tmux(tmux.session_name_for(s.cwd), s.sid[:8], cmd)
155
155
 
156
156
 
157
157
  def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
@@ -173,7 +173,7 @@ def attach_target(s: Session) -> str | None:
173
173
  a bare terminal (those need `do_tmux_resume`)."""
174
174
  if not s.alive or not s.pid:
175
175
  return None
176
- return rc.find_session_window([s.pid])
176
+ return tmux.find_session_window([s.pid])
177
177
 
178
178
 
179
179
  def tmux_foreground_cmd(s: Session) -> str:
@@ -205,7 +205,7 @@ def do_tmux_new(directory: str) -> str | None:
205
205
  either: the user lands inside the window, so claude's own trust dialog
206
206
  shows interactively."""
207
207
  cmd = f"cd {shlex.quote(directory)} && claude"
208
- return rc.run_in_tmux(rc.session_name_for(directory), "claude", cmd)
208
+ return tmux.run_in_tmux(tmux.session_name_for(directory), "claude", cmd)
209
209
 
210
210
 
211
211
  # --- exit intents (the payload crossing the exit-then-exec seam) ------------
@@ -282,10 +282,10 @@ def enter_window(target: str) -> bool:
282
282
  return so the caller exits csctl normally — both paths end csctl, keeping
283
283
  "接回 = 离开 csctl" uniform. select-window failure is non-fatal (the user
284
284
  lands in the session and can pick the window by hand)."""
285
- rc.select_window(target)
285
+ tmux.select_window(target)
286
286
  session = target.split(":", 1)[0]
287
287
  if os.environ.get("TMUX"):
288
- return rc.switch_client(target)
288
+ return tmux.switch_client(target)
289
289
  try:
290
290
  os.execvp("tmux", ["tmux", "attach-session", "-t", session])
291
291
  except OSError:
@@ -136,21 +136,31 @@ def _cmd_rc(args: argparse.Namespace) -> None:
136
136
 
137
137
 
138
138
  def _cmd_prune(args: argparse.Namespace) -> None:
139
+ from .data import liveness
139
140
  from .data.cleanup import (
140
- cleanup_stats,
141
+ build_plan,
141
142
  execute_orphan_removals,
142
143
  execute_session_removals,
143
- list_orphan_dirs,
144
144
  prune_sessions,
145
145
  )
146
146
  from .data.sessions import scan
147
147
 
148
148
  sessions = scan()
149
- stats = cleanup_stats(sessions)
150
- print(f"Total: {stats['total']} Empty: {stats['empty']} Short(<=2): {stats['short']} Orphans: {stats['orphans']}")
149
+ # One shared fetch feeds the frozen plan — the SAME assembly build_plan's
150
+ # other callers (the Sessions view, build_world_snapshot) use, so the CLI
151
+ # header and any future TUI parity stay derived from one source (删除 ⊆ 预览
152
+ # still holds: execute_* revalidate against fresh data at apply time).
153
+ procs, cur, jobs, agents = liveness.liveness_inputs()
154
+ plan = build_plan(sessions, procs, cur, jobs, agents)
155
+ counts = plan.counts()
156
+ print(
157
+ f"Total: {len(sessions)} Prunable empty: {counts['empty']} "
158
+ f"short(<=2): {counts['short']} Orphan dirs: {counts['orphan_dirs']} "
159
+ f"Zombie files: {counts['zombie_procs']} Aged: {counts['aged_entries']}"
160
+ )
151
161
 
152
162
  if args.sweep_orphans:
153
- orphans = list_orphan_dirs(sessions)
163
+ orphans = plan.orphan_entries
154
164
  print(f"Would sweep {len(orphans)} orphan artifact dir(s)")
155
165
  if not args.apply:
156
166
  print("Dry run. Add --apply to execute.")
@@ -80,9 +80,10 @@ def _session_artifact_paths(sid: str) -> list[str]:
80
80
  """All on-disk artifact paths owned by one session id (cfg-derived).
81
81
 
82
82
  Covers the sid-keyed dirs plus the 8-char-prefixed `jobs/<short>` dir for
83
- this session. Used by `agent_ops.remove_job` (which has already alive-gated
84
- the job). `remove_session` does NOT use this — it guards the `jobs/<short>`
85
- path separately so a LIVE agent worker's jobs dir is never deleted (M3).
83
+ this session. Used by `remove_agent_artifacts` (whose caller,
84
+ `agent_ops.remove_job`, has already alive-gated the job). `remove_session`
85
+ does NOT use this it guards the `jobs/<short>` path separately so a LIVE
86
+ agent worker's jobs dir is never deleted (M3).
86
87
  """
87
88
  return _sid_keyed_paths(sid) + [_jobs_path(sid)]
88
89
 
@@ -101,6 +102,26 @@ def _remove_path(path: str) -> bool:
101
102
  return False
102
103
 
103
104
 
105
+ def remove_agent_artifacts(short: str, sid: str) -> bool:
106
+ """Delete a settled background agent's `jobs/<short>` dir + sid artifacts.
107
+
108
+ Removes `jobs/<short>` first (the return value is True iff THAT removal
109
+ happened), then every path in `_session_artifact_paths(sid)` (the sid-keyed
110
+ dirs plus `jobs/<sid[:8]>` — a second remove of the same job dir when
111
+ `short == sid[:8]` is a harmless no-op).
112
+
113
+ The CALLER owns the gates: this function assumes the job has already been
114
+ alive-gated (a LIVE worker must never reach here) and that
115
+ `proc.current_determinable()` (R10) has already been checked — it only
116
+ deletes.
117
+ """
118
+ job_dir = os.path.join(str(cfg.jobs_dir), short)
119
+ removed = _remove_path(job_dir)
120
+ for path in _session_artifact_paths(sid):
121
+ _remove_path(path)
122
+ return removed
123
+
124
+
104
125
  # --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
105
126
 
106
127
  def known_sids(
@@ -141,6 +162,33 @@ def known_sids(
141
162
  return known
142
163
 
143
164
 
165
+ def _fill_liveness_inputs(
166
+ session_procs: list[SessionProc] | None,
167
+ agent_jobs: list[AgentJob] | None,
168
+ agents_map: dict[str, int | None] | None,
169
+ cur: set[int] | None,
170
+ ) -> tuple[list[SessionProc], list[AgentJob], dict[str, int | None], set[int]]:
171
+ """Fill any None input from `liveness.liveness_inputs()` (the ONE assembly).
172
+
173
+ Snapshot/view callers inject the shared world data (R11) and pay no IO
174
+ here; CLI / no-snapshot callers pass None and get the same assembly
175
+ `build_world_snapshot` uses (which swallows each source's errors → safe
176
+ empties). A caller that doesn't consume a source passes `[]`/`{}` — not
177
+ None — so an unused gap never triggers the fetch on its own.
178
+ """
179
+ if session_procs is None or agent_jobs is None or agents_map is None or cur is None:
180
+ d_procs, d_cur, d_jobs, d_agents = liveness.liveness_inputs()
181
+ if session_procs is None:
182
+ session_procs = d_procs
183
+ if agent_jobs is None:
184
+ agent_jobs = d_jobs
185
+ if agents_map is None:
186
+ agents_map = d_agents
187
+ if cur is None:
188
+ cur = d_cur
189
+ return session_procs, agent_jobs, agents_map, cur
190
+
191
+
144
192
  def _gather_known(
145
193
  sessions: list[Session],
146
194
  session_procs: list[SessionProc] | None = None,
@@ -148,26 +196,10 @@ def _gather_known(
148
196
  agents_map: dict[str, int | None] | None = None,
149
197
  cur: set[int] | None = None,
150
198
  ) -> set[str]:
151
- """Resolve the protected-sid set, self-fetching any omitted source.
152
-
153
- Snapshot/view callers inject the shared world data (R11); CLI / no-snapshot
154
- callers pass nothing and we read the (TTL-cached) registry + `alive_map`. Each
155
- self-read swallows its own errors → safe empties.
156
- """
157
- if session_procs is None:
158
- session_procs = liveness.live_session_procs()
159
- if agent_jobs is None:
160
- try:
161
- agent_jobs = registry.read_agent_jobs()
162
- except Exception:
163
- agent_jobs = []
164
- if agents_map is None:
165
- try:
166
- agents_map = liveness.alive_map()
167
- except Exception:
168
- agents_map = {}
169
- if cur is None:
170
- cur = proc.ancestor_pids()
199
+ """Resolve the protected-sid set, self-fetching any omitted source
200
+ (`_fill_liveness_inputs`)."""
201
+ session_procs, agent_jobs, agents_map, cur = _fill_liveness_inputs(
202
+ session_procs, agent_jobs, agents_map, cur)
171
203
  return known_sids(sessions, session_procs, agent_jobs, agents_map, cur)
172
204
 
173
205
 
@@ -399,18 +431,10 @@ def execute_session_removals(
399
431
  """
400
432
  if not proc.current_determinable():
401
433
  return 0
402
- # This self-fetch mirrors snapshot.liveness_inputs()'s assembly cleanup
403
- # sits BELOW snapshot in the data DAG and cannot import it; keep the two
404
- # in sync when the liveness sources change.
405
- if session_procs is None:
406
- session_procs = liveness.live_session_procs()
407
- if agents_map is None:
408
- try:
409
- agents_map = liveness.alive_map()
410
- except Exception:
411
- agents_map = {}
412
- if cur is None:
413
- cur = proc.ancestor_pids()
434
+ # agent_jobs is unused here: pass [] (not None) so an omitted-but-unused
435
+ # source never triggers the fetch on its own (see _fill_liveness_inputs).
436
+ session_procs, _, agents_map, cur = _fill_liveness_inputs(
437
+ session_procs, [], agents_map, cur)
414
438
  fresh_alive = {sp.sid for sp in session_procs if sp.proc_alive}
415
439
  fresh_alive |= {sid for sid, pid in agents_map.items() if pid}
416
440
  count = 0
@@ -470,39 +494,3 @@ def build_plan(
470
494
  zombie_pids=select_zombie_pids(session_procs, cur),
471
495
  aged_entries=list_aged_entries(now),
472
496
  )
473
-
474
-
475
- # --- Classified counts -----------------------------------------------------
476
-
477
- def cleanup_stats(sessions: list[Session]) -> dict[str, int]:
478
- """Summary counts for the Sessions cleanup submenu (view-facing contract).
479
-
480
- Keeps the established 4-key shape (`total/empty/short/orphans`) that the
481
- view reads; `orphans` is the sid-keyed orphan count (Strategy A).
482
- """
483
- return {
484
- "total": len(sessions),
485
- "empty": sum(1 for s in sessions if s.prompts == 0),
486
- "short": sum(1 for s in sessions if 0 < s.prompts <= 2),
487
- "orphans": len(list_orphan_dirs(sessions)),
488
- }
489
-
490
-
491
- def cleanup_classified(
492
- sessions: list[Session],
493
- session_procs: list[SessionProc],
494
- cur: set[int],
495
- agent_jobs: list[AgentJob] | None = None,
496
- agents_map: dict[str, int | None] | None = None,
497
- now: float | None = None,
498
- ) -> dict[str, int]:
499
- """Per-category cleanup counts (D6) — a thin view over `build_plan`.
500
-
501
- Deriving the counts FROM the plan keeps them equal to what a preview would
502
- show: `empty`/`short` count *prunable* sessions (not alive/current/recent),
503
- matching the preview list instead of the raw prompt tally.
504
- """
505
- return build_plan(
506
- sessions, session_procs, cur,
507
- agent_jobs=agent_jobs, agents_map=agents_map, now=now,
508
- ).counts()
@@ -20,6 +20,30 @@ _cache: dict[str, int | None] | None = None
20
20
  _cache_time: float = 0.0
21
21
 
22
22
 
23
+ def liveness_inputs() -> tuple[
24
+ list[SessionProc], set[int], list[AgentJob], dict[str, int | None]
25
+ ]:
26
+ """The shared liveness inputs — `(session_procs, cur, agent_jobs,
27
+ agents_map)` — fetched ONCE, jobs already host-enriched.
28
+
29
+ `build_world_snapshot`, the Sessions view's `fetch_pending(None)`
30
+ self-fetch, and `cleanup`'s protection-set assembly all consume this, so
31
+ the degraded/self-fetch paths are the same assembly instead of a
32
+ hand-kept mirror that can drift. Each read swallows its own errors → safe
33
+ empties.
34
+ """
35
+ session_procs = live_session_procs()
36
+ try:
37
+ agent_jobs = enrich_jobs(registry.read_agent_jobs(), session_procs)
38
+ except Exception:
39
+ agent_jobs = []
40
+ try:
41
+ agents_map = alive_map()
42
+ except Exception:
43
+ agents_map = {}
44
+ return session_procs, proc.ancestor_pids(), agent_jobs, agents_map
45
+
46
+
23
47
  def _scrub_dead_pids(
24
48
  mapping: dict[str, int | None],
25
49
  exists: Callable[[int | None], bool],
@@ -6,12 +6,11 @@ import json
6
6
  import os
7
7
  import re
8
8
  import shlex
9
- import subprocess
10
9
  import time
11
10
 
12
11
  from ..config import cfg
13
12
  from ..models import EnvRecord, RCProject, RCServer, split_env_id
14
- from . import environments, proc
13
+ from . import environments, proc, tmux
15
14
 
16
15
  # Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
17
16
  _ENV_ID_RE = re.compile(r"env_[A-Za-z0-9]+")
@@ -102,199 +101,33 @@ def is_trusted(proj: str) -> bool:
102
101
  return False
103
102
 
104
103
 
105
- # --- tmux adapter ---------------------------------------------------------
106
- # Single seam over the tmux CLI. Only `_tmux_run` touches `subprocess`; every
107
- # other tmux call routes through a verb wrapper. Each wrapper keeps the
108
- # swallow-errors contract (return empty/False/None on any failure).
104
+ # --- RC-scoped thin delegates over data/tmux.py ---------------------------
105
+ # Bound to `cfg.rc_session`. The generic tmux adapter lives in `data/tmux.py`
106
+ # (the single seam only its `_tmux_run` touches `subprocess`); these four
107
+ # stay here (rather than inlined at call sites) because `scan`/`scan_servers`
108
+ # and their tests poke these exact RC-scoped names.
109
109
 
110
110
 
111
- def _tmux_run(args: list[str]) -> subprocess.CompletedProcess | None:
112
- """Run one `tmux <args>` command; return the result, or None on failure."""
113
- try:
114
- return subprocess.run(
115
- ["tmux", *args],
116
- capture_output=True, text=True, timeout=5,
117
- )
118
- except Exception:
119
- return None
111
+ def _tmux_windows() -> list[str]:
112
+ return tmux.list_windows(cfg.rc_session)
120
113
 
121
114
 
122
- def _tmux_list_windows() -> list[str]:
123
- cp = _tmux_run(["list-windows", "-t", cfg.rc_session, "-F", "#W"])
124
- if cp is None:
125
- return []
126
- return [line.strip() for line in cp.stdout.splitlines() if line.strip()]
115
+ def _tmux_window_pids() -> dict[str, int]:
116
+ return tmux.window_pids(cfg.rc_session)
127
117
 
128
118
 
129
119
  def _tmux_pane_alive(target: str) -> bool:
130
- cp = _tmux_run(["list-panes", "-t", target, "-F", "#{pane_dead}"])
131
- if cp is None:
132
- return False
133
- return cp.stdout.strip().split("\n")[0] == "0"
134
-
135
-
136
- def _tmux_window_pids() -> dict[str, int]:
137
- """{window_name: pane_pid} for the rc session; {} on failure.
138
-
139
- The pane pid IS the RC server pid because `start_one` runs `exec claude …`
140
- (the shell is replaced). Used to classify /proc-discovered servers as
141
- managed (pid in this set) vs external.
142
- """
143
- cp = _tmux_run(
144
- ["list-windows", "-t", cfg.rc_session, "-F", "#W\t#{pane_pid}"]
145
- )
146
- if cp is None:
147
- return {}
148
- out: dict[str, int] = {}
149
- for line in cp.stdout.splitlines():
150
- name, _, pid_s = line.partition("\t")
151
- name = name.strip()
152
- try:
153
- pid = int(pid_s.strip())
154
- except ValueError:
155
- continue
156
- if name:
157
- out[name] = pid
158
- return out
120
+ return tmux.pane_alive(target)
159
121
 
160
122
 
161
123
  def _tmux_capture_pane(target: str) -> str:
162
- """Full scrollback of a tmux pane as text; "" on failure.
163
-
164
- Captures from the start of history (`-S -`) so an `env_*` id printed at
165
- server startup is still grep-able after it scrolls off the visible region.
166
- """
167
- cp = _tmux_run(["capture-pane", "-p", "-S", "-", "-t", target])
168
- if cp is None:
169
- return ""
170
- return cp.stdout
171
-
172
-
173
- def _tmux_has_session(session: str) -> bool:
174
- cp = _tmux_run(["has-session", "-t", session])
175
- return cp is not None and cp.returncode == 0
176
-
177
-
178
- # -P -F makes tmux print the exact target of the window it just created, so
179
- # callers enter THAT window even when names collide (no select-by-name guess).
180
- _TARGET_FMT = "#{session_name}:#{window_index}"
181
-
182
-
183
- def _spawned_target(cp: subprocess.CompletedProcess | None) -> str | None:
184
- if cp is None or cp.returncode != 0:
185
- return None
186
- return cp.stdout.strip() or None
187
-
188
-
189
- def _tmux_new_window(session: str, name: str, cmd: str) -> str | None:
190
- """Create a window; return its exact "session:index" target, or None."""
191
- cp = _tmux_run(["new-window", "-P", "-F", _TARGET_FMT, "-t", session, "-n", name, cmd])
192
- return _spawned_target(cp)
193
-
194
-
195
- def _tmux_new_session(session: str, name: str, cmd: str) -> str | None:
196
- """Create a detached session; return its window's target, or None."""
197
- cp = _tmux_run(["new-session", "-d", "-P", "-F", _TARGET_FMT, "-s", session, "-n", name, cmd])
198
- return _spawned_target(cp)
199
-
200
-
201
- def _tmux_kill_window(target: str) -> bool:
202
- cp = _tmux_run(["kill-window", "-t", target])
203
- return cp is not None and cp.returncode == 0
204
-
205
-
206
- def _tmux_kill_session(session: str) -> bool:
207
- cp = _tmux_run(["kill-session", "-t", session])
208
- return cp is not None and cp.returncode == 0
209
-
210
-
211
- def _tmux_windows() -> list[str]:
212
- return _tmux_list_windows()
124
+ return tmux.capture_pane(target)
213
125
 
214
126
 
215
127
  def _is_alive(proj: str) -> bool:
216
128
  return _tmux_pane_alive(f"{cfg.rc_session}:{proj}")
217
129
 
218
130
 
219
- def session_name_for(cwd: str) -> str:
220
- """tmux session name for a project directory: its basename, with the tmux
221
- target separators `.`/`:` (illegal in session names) replaced by `-`.
222
-
223
- One session per project — the grouping rule shared by every claude spawn
224
- (t 新建 / t 接回 / R 转入后台 / agents respawn). Empty cwd → "claude"."""
225
- base = cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else ""
226
- name = base.replace(".", "-").replace(":", "-").strip()
227
- return name or "claude"
228
-
229
-
230
- def run_in_tmux(session: str, window: str, cmd: str) -> str | None:
231
- """Run `cmd` in a tmux `window` under `session`, creating the session if
232
- it doesn't exist yet. Returns the exact "session:window_index" target of
233
- the new window (None on failure) so callers can enter it unambiguously.
234
- Public seam for relaunching a session outside the managed RC server
235
- machinery."""
236
- if _tmux_has_session(session):
237
- return _tmux_new_window(session, window, cmd)
238
- return _tmux_new_session(session, window, cmd)
239
-
240
-
241
- def _tmux_list_all_panes() -> list[tuple[str, int]]:
242
- """[(\"session:window_index\", pane_pid)] across ALL tmux sessions; [] on failure."""
243
- cp = _tmux_run(
244
- ["list-panes", "-a", "-F", "#{session_name}:#{window_index}\t#{pane_pid}"]
245
- )
246
- if cp is None:
247
- return []
248
- out: list[tuple[str, int]] = []
249
- for line in cp.stdout.splitlines():
250
- target, _, pid_s = line.partition("\t")
251
- try:
252
- out.append((target.strip(), int(pid_s.strip())))
253
- except ValueError:
254
- continue
255
- return out
256
-
257
-
258
- def window_containing(
259
- panes: list[tuple[str, int]], ancestors: set[int]
260
- ) -> str | None:
261
- """PURE: the pane target whose pane_pid appears in `ancestors`, or None.
262
-
263
- A session process hosted by a tmux pane has that pane's pid in its
264
- ancestor chain (the pane pid is the window's root process)."""
265
- for target, pane_pid in panes:
266
- if pane_pid in ancestors:
267
- return target
268
- return None
269
-
270
-
271
- def find_session_window(pids: list[int]) -> str | None:
272
- """The tmux window (\"session:index\") hosting any of `pids`, or None.
273
-
274
- Walks each pid's `/proc` ancestor chain and matches it against every tmux
275
- pane's root pid — finds windows in ANY tmux session (cc, rc, user-made)."""
276
- panes = _tmux_list_all_panes()
277
- if not panes:
278
- return None
279
- for pid in pids:
280
- target = window_containing(panes, proc.ancestors_of(pid))
281
- if target:
282
- return target
283
- return None
284
-
285
-
286
- def select_window(target: str) -> bool:
287
- """tmux select-window -t <target>; False on failure (non-fatal for attach)."""
288
- cp = _tmux_run(["select-window", "-t", target])
289
- return cp is not None and cp.returncode == 0
290
-
291
-
292
- def switch_client(target: str) -> bool:
293
- """tmux switch-client -t <target> — the inside-tmux attach equivalent."""
294
- cp = _tmux_run(["switch-client", "-t", target])
295
- return cp is not None and cp.returncode == 0
296
-
297
-
298
131
  def _read_rc_at_startup(directory: str) -> bool | None:
299
132
  for name in ("settings.local.json", "settings.json"):
300
133
  path = os.path.join(directory, ".claude", name)
@@ -456,15 +289,15 @@ def start_one(proj: str) -> bool:
456
289
  f"--name {shlex.quote(remote_name)} --spawn same-dir"
457
290
  )
458
291
 
459
- return run_in_tmux(cfg.rc_session, proj, cmd) is not None
292
+ return tmux.run_in_tmux(cfg.rc_session, proj, cmd) is not None
460
293
 
461
294
 
462
295
  def stop_one(proj: str) -> bool:
463
- return _tmux_kill_window(f"{cfg.rc_session}:{proj}")
296
+ return tmux.kill_window(f"{cfg.rc_session}:{proj}")
464
297
 
465
298
 
466
299
  def stop_all() -> bool:
467
- return _tmux_kill_session(cfg.rc_session)
300
+ return tmux.kill_session(cfg.rc_session)
468
301
 
469
302
 
470
303
  def start_many(projects: list[str]) -> int:
@@ -6,10 +6,10 @@ computes that world ONCE on the worker thread; `App` then hands the same
6
6
  immutable snapshot to each view's `fetch_pending(snapshot)` so they only project
7
7
  it (no per-view IO). Views stay back-compatible: `fetch_pending(None)` self-fetches.
8
8
 
9
- This is the TOP of the data layer — it composes `sessions` / `rc` / `registry` /
10
- `environments` / `proc`. Nothing in `data/` imports it (only `app`/`views` do),
11
- so there is no cycle. Errors are swallowed by the callees; `App` additionally
12
- guards `build_world_snapshot` so a failed build degrades to per-view self-fetch.
9
+ This is the TOP of the data layer — it composes `sessions` / `rc` / `liveness` /
10
+ `environments`. Nothing in `data/` imports it (only `app`/`views` do), so there
11
+ is no cycle. Errors are swallowed by the callees; `App` additionally guards
12
+ `build_world_snapshot` so a failed build degrades to per-view self-fetch.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  from dataclasses import dataclass, field
18
18
 
19
19
  from ..models import AgentJob, EnvRecord, RCProject, RCServer, Session, SessionProc
20
- from . import environments, liveness, proc, rc, registry, sessions
20
+ from . import environments, liveness, rc, sessions
21
21
 
22
22
 
23
23
  @dataclass
@@ -35,7 +35,7 @@ class WorldSnapshot:
35
35
  `session_procs` (with `/proc` liveness already injected), `agents_map`
36
36
  (`claude agents --json`) and `cur` (the ancestor-pid set) are the raw liveness
37
37
  inputs `build_world_snapshot` already computes for the scan; they are exposed
38
- here so the Sessions cleanup submenu can feed `cleanup_classified` /
38
+ here so the Sessions cleanup submenu can feed `cleanup.build_plan` /
39
39
  `select_zombie_pids` WITHOUT a second scan (R11/D8).
40
40
  """
41
41
  sessions: list[Session] = field(default_factory=list)
@@ -49,29 +49,6 @@ class WorldSnapshot:
49
49
  cur: set[int] = field(default_factory=set)
50
50
 
51
51
 
52
- def liveness_inputs() -> tuple[
53
- list[SessionProc], set[int], list[AgentJob], dict[str, int | None]
54
- ]:
55
- """The shared liveness inputs — `(session_procs, cur, agent_jobs,
56
- agents_map)` — fetched ONCE, jobs already host-enriched.
57
-
58
- Both `build_world_snapshot` and the Sessions view's `fetch_pending(None)`
59
- self-fetch consume this, so the degraded path is the same assembly instead
60
- of a hand-kept mirror that can drift. Each read swallows its own errors →
61
- safe empties.
62
- """
63
- session_procs = liveness.live_session_procs()
64
- try:
65
- agent_jobs = liveness.enrich_jobs(registry.read_agent_jobs(), session_procs)
66
- except Exception:
67
- agent_jobs = []
68
- try:
69
- agents_map = liveness.alive_map()
70
- except Exception:
71
- agents_map = {}
72
- return session_procs, proc.ancestor_pids(), agent_jobs, agents_map
73
-
74
-
75
52
  def build_world_snapshot() -> WorldSnapshot:
76
53
  """Compute the shared per-cycle world once (worker thread, R11/D8).
77
54
 
@@ -80,7 +57,7 @@ def build_world_snapshot() -> WorldSnapshot:
80
57
  registry reads are ~5s-TTL cached so the few repeat reads inside `scan()`
81
58
  hit the cache. Each callee swallows its own errors and returns safe empties.
82
59
  """
83
- session_procs, cur, agent_jobs, agents_map = liveness_inputs()
60
+ session_procs, cur, agent_jobs, agents_map = liveness.liveness_inputs()
84
61
  all_sessions = sessions.scan()
85
62
  rc_projects = rc.scan()
86
63
  rc_servers = rc.scan_servers()