cc-session-control 0.5.0__tar.gz → 0.6.0__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 (58) hide show
  1. {cc_session_control-0.5.0/src/cc_session_control.egg-info → cc_session_control-0.6.0}/PKG-INFO +2 -2
  2. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/README.md +1 -1
  3. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/agent_ops.py +5 -4
  5. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/session_ops.py +77 -2
  6. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/app.py +45 -24
  7. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/cli.py +25 -2
  8. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/config.py +0 -4
  9. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/liveness.py +32 -4
  10. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/proc.py +29 -10
  11. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/rc.py +93 -15
  12. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/skill/SKILL.md +1 -1
  13. cc_session_control-0.6.0/src/cc_session_control/views/_colspec.py +49 -0
  14. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/_session_row.py +46 -25
  15. cc_session_control-0.6.0/src/cc_session_control/views/_sessions_cleanup.py +163 -0
  16. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/agents.py +23 -17
  17. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/rc.py +68 -131
  18. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/sessions.py +48 -155
  19. {cc_session_control-0.5.0 → cc_session_control-0.6.0/src/cc_session_control.egg-info}/PKG-INFO +2 -2
  20. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/SOURCES.txt +2 -0
  21. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_agent_ops.py +3 -2
  22. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_agents.py +1 -1
  23. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_app.py +14 -15
  24. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_data.py +138 -8
  25. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_liveness.py +79 -0
  26. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_views.py +171 -70
  27. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/LICENSE +0 -0
  28. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/pyproject.toml +0 -0
  29. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/setup.cfg +0 -0
  30. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/__main__.py +0 -0
  31. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/__init__.py +0 -0
  32. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/resume_list.py +0 -0
  33. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/skill_ops.py +0 -0
  34. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/clipboard.py +0 -0
  35. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/__init__.py +0 -0
  36. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/agents.py +0 -0
  37. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/cleanup.py +0 -0
  38. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/environments.py +0 -0
  39. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/registry.py +0 -0
  40. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/sessions.py +0 -0
  41. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/snapshot.py +0 -0
  42. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/models.py +0 -0
  43. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/__init__.py +0 -0
  44. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  45. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  46. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/requires.txt +0 -0
  47. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
  48. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_cleanup.py +0 -0
  49. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_cli.py +0 -0
  50. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_environments.py +0 -0
  51. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_proc.py +0 -0
  52. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_rc.py +0 -0
  53. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_registry.py +0 -0
  54. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_resume_list.py +0 -0
  55. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_sessions.py +0 -0
  56. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_skill_ops.py +0 -0
  57. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_smoke.py +0 -0
  58. {cc_session_control-0.5.0 → cc_session_control-0.6.0}/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.5.0
3
+ Version: 0.6.0
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -30,7 +30,7 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
30
30
  ## Features
31
31
 
32
32
  - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
33
- - **Remote Control Tab** — Start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
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
34
  - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
35
35
 
36
36
  Built with [urwid](https://urwid.org/).
@@ -7,7 +7,7 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
7
7
  ## Features
8
8
 
9
9
  - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
10
- - **Remote Control Tab** — Start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
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
11
  - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
12
12
 
13
13
  Built with [urwid](https://urwid.org/).
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -97,12 +97,13 @@ def _job_window(job: AgentJob) -> str:
97
97
  def respawn(job: AgentJob) -> str:
98
98
  """Relaunch a background agent in tmux; returns the exact command string.
99
99
 
100
- Runs `respawn_cmd(job)` in the shared tmux session (`cfg.tmux_session`) so it
101
- outlives the terminal — it does NOT os.exec/replace the csctl process. The
102
- returned string also feeds the clipboard `y`-style key.
100
+ Runs `respawn_cmd(job)` in the job's per-project tmux session
101
+ (`rc.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
102
+ os.exec/replace the csctl process. The returned string also feeds the
103
+ clipboard `y`-style key.
103
104
  """
104
105
  cmd = respawn_cmd(job)
105
- rc.run_in_tmux(cfg.tmux_session, _job_window(job), cmd)
106
+ rc.run_in_tmux(rc.session_name_for(job.cwd), _job_window(job), cmd)
106
107
  return cmd
107
108
 
108
109
 
@@ -8,7 +8,6 @@ import signal
8
8
  import time
9
9
 
10
10
  from .. import clipboard
11
- from ..config import cfg
12
11
  from ..data import proc, rc
13
12
  from ..data.liveness import invalidate_cache
14
13
  from ..models import Session
@@ -143,7 +142,83 @@ def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
143
142
  pass
144
143
  time.sleep(1)
145
144
  invalidate_cache()
146
- return rc.run_in_tmux(cfg.tmux_session, _rc_name(s), tmux_resume_cmd(s, fork))
145
+ session = rc.session_name_for(s.cwd)
146
+ return rc.run_in_tmux(session, s.sid[:8], tmux_resume_cmd(s, fork)) is not None
147
+
148
+
149
+ def attach_target(s: Session) -> str | None:
150
+ """The tmux window ("session:index") already hosting this live session.
151
+
152
+ Non-None means the `t` key can attach directly — no kill, no respawn (the
153
+ session survives in place). None for dead sessions or live ones running in
154
+ a bare terminal (those need `do_tmux_resume`)."""
155
+ if not s.alive or not s.pid:
156
+ return None
157
+ return rc.find_session_window([s.pid])
158
+
159
+
160
+ def tmux_foreground_cmd(s: Session) -> str:
161
+ """Shell command for the `t`-key window: plain resume, NO --remote-control.
162
+
163
+ Deliberately unlike `tmux_resume_cmd` (the `R` key): a foreground tmux
164
+ resume only needs the disconnect safety net; every --remote-control process
165
+ mints a new cloud environment entry, which piles up with frequent use."""
166
+ cwd, args, _ = _resume_plan(s)
167
+ line = shlex.join(args)
168
+ return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
169
+
170
+
171
+ def do_tmux_resume(s: Session) -> str | None:
172
+ """Kill-if-takeover, spawn the resume window in the session's per-project
173
+ tmux session, and return the exact tmux target to enter; None on failure.
174
+
175
+ Mirrors `relaunch_in_tmux`'s kill handling (same `_resume_plan` should_kill
176
+ + R10 refusal); the caller (cli) then calls `enter_window` on the target."""
177
+ _, _, should_kill = _resume_plan(s)
178
+ if should_kill and s.pid:
179
+ if not proc.current_determinable():
180
+ return None
181
+ try:
182
+ os.kill(s.pid, signal.SIGTERM)
183
+ except Exception:
184
+ pass
185
+ time.sleep(1)
186
+ invalidate_cache()
187
+ session = rc.session_name_for(s.cwd)
188
+ return rc.run_in_tmux(session, s.sid[:8], tmux_foreground_cmd(s))
189
+
190
+
191
+ def do_tmux_new(directory: str) -> str | None:
192
+ """Start a NEW claude session in `directory`, inside that project's own
193
+ tmux session, and return the exact tmux target to enter; None on failure.
194
+
195
+ The 项目-tab `t` key: same skeleton as `do_tmux_resume` but nothing exists
196
+ yet — no kill, no confirm, no R10 gate (no process is terminated). Plain
197
+ `claude` with NO --remote-control (same tradeoff as `tmux_foreground_cmd`:
198
+ every RC process mints a new cloud environment entry). No trust gate
199
+ either: the user lands inside the window, so claude's own trust dialog
200
+ shows interactively."""
201
+ cmd = f"cd {shlex.quote(directory)} && claude"
202
+ return rc.run_in_tmux(rc.session_name_for(directory), "claude", cmd)
203
+
204
+
205
+ def enter_window(target: str) -> bool:
206
+ """Bring `target` ("session:window") to the user's terminal foreground.
207
+
208
+ Outside tmux: exec `tmux attach-session` — replaces the csctl process (does
209
+ not return on success). Inside tmux ($TMUX set): `switch-client`, then
210
+ return so the caller exits csctl normally — both paths end csctl, keeping
211
+ "接回 = 离开 csctl" uniform. select-window failure is non-fatal (the user
212
+ lands in the session and can pick the window by hand)."""
213
+ rc.select_window(target)
214
+ session = target.split(":", 1)[0]
215
+ if os.environ.get("TMUX"):
216
+ return rc.switch_client(target)
217
+ try:
218
+ os.execvp("tmux", ["tmux", "attach-session", "-t", session])
219
+ except OSError:
220
+ return False
221
+ return True # unreachable after a successful exec; keeps type-checkers calm
147
222
 
148
223
 
149
224
  def to_clipboard(text: str) -> bool:
@@ -43,23 +43,28 @@ class TabView(Protocol):
43
43
  def handle_key(self, key: str) -> None: ...
44
44
 
45
45
  # 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
46
+ # ONE semantic set — views reference these names only (no per-tab duplicates;
47
+ # the old rc_running/rc_stopped were aliases of alive/dead and are gone).
48
+ # 256-color foregrounds are kept ≥ 4.5:1 against their background (WCAG-AA-ish,
49
+ # checked via relative luminance): #aaa/#111 ≈ 8.1, #7ab/#111 ≈ 7.4,
50
+ # #9cc/#181818 ≈ 10.0, #bbb/#111 ≈ 9.8, #d66/#111 ≈ 5.5.
46
51
  PALETTE = [
47
- ("header", "white,bold", "black", "bold", "#fff,bold", "#111"),
48
- ("footer", "light gray", "black", None, "#999", "#111"),
49
- ("tab_on", "white,bold", "dark cyan", "bold,standout", "#fff,bold", "#068"),
50
- ("tab_off", "dark cyan", "black", None, "#688", "#111"),
51
- ("alive", "light green", "black", None, "#6d6", "#111"),
52
- ("dead", "light gray", "black", None, "#ccc", "#111"),
53
- ("selected", "white,bold", "dark cyan", "standout", "#fff,bold", "#068"),
54
- ("notify", "yellow,bold", "black", "bold", "#ff0,bold", "#111"),
55
- ("status", "light gray", "black", None, "#aaa", "#111"),
56
- ("rc_running", "light green", "black", None, "#6d6", "#111"),
57
- ("rc_stopped", "light gray", "black", None, "#ccc", "#111"),
58
- ("body", "light gray", "black", None, "#ccc", "#111"),
59
- ("col_header", "dark cyan", "black", None, "#8aa", "#181818"),
52
+ ("header", "white,bold", "black", "bold", "#fff,bold", "#111"),
53
+ ("footer", "light gray", "black", None, "#aaa", "#111"),
54
+ ("tab_on", "white,bold", "dark cyan", "bold,standout", "#fff,bold", "#068"),
55
+ ("tab_off", "light cyan", "black", None, "#7ab", "#111"),
56
+ ("alive", "light green", "black", None, "#6d6", "#111"),
57
+ ("status_busy", "light green,bold", "black", "bold", "#6d6,bold", "#111"),
58
+ ("status_err", "light red", "black", None, "#d66", "#111"),
59
+ ("dead", "light gray", "black", None, "#ccc", "#111"),
60
+ ("selected", "white,bold", "dark cyan", "standout", "#fff,bold", "#068"),
61
+ ("notify", "yellow,bold", "black", "bold", "#ff0,bold", "#111"),
62
+ ("status", "light gray", "black", None, "#bbb", "#111"),
63
+ ("body", "light gray", "black", None, "#ccc", "#111"),
64
+ ("col_header", "dark cyan", "black", None, "#9cc", "#181818"),
60
65
  ]
61
66
 
62
- TAB_NAMES = ["会话", "后台", "远程控制"]
67
+ TAB_NAMES = ["会话", "项目", "后台"]
63
68
 
64
69
  # D1: all three tabs share ONE footer prefix — the universal verbs (Tab/q/r) live
65
70
  # here exactly once so `r 刷新` shows identically on every tab. View-specific keys
@@ -87,13 +92,14 @@ class App:
87
92
  self._alarm_handle: object | None = None
88
93
  self._pipe_fd: int | None = None
89
94
  self._refreshing = False
90
- # When set, a y/n confirm modal is up: `_input` routes y/n/esc here and
95
+ # When set, a confirm modal is up: `_input` routes Enter/y/n/esc here and
91
96
  # swallows every other key (App-level so all tabs share it — D8 view files
92
97
  # stay small). `_confirm_base` is the widget restored on close.
93
98
  self._confirm_yes: Callable[[], None] | None = None
94
99
  self._confirm_base: urwid.Widget | None = None
95
100
 
96
- self.views: list[TabView] = [SessionsView(self), AgentsView(self), RCView(self)]
101
+ # Order is in lockstep with TAB_NAMES: 会话 / 项目 / 后台.
102
+ self.views: list[TabView] = [SessionsView(self), RCView(self), AgentsView(self)]
97
103
  self._active = 0
98
104
 
99
105
  self.body = urwid.WidgetPlaceholder(self.views[0].widget)
@@ -143,9 +149,12 @@ class App:
143
149
 
144
150
  def _input(self, key: str) -> None:
145
151
  if self._confirm_yes is not None:
146
- # Modal: only y/n/esc are live; tab/q/everything else is swallowed so
147
- # a destructive confirm can't be skipped past by an accidental key.
148
- if key == "y":
152
+ # Modal: only Enter/y/n/esc are live; tab/q/everything else is
153
+ # swallowed so a destructive confirm can't be skipped past by an
154
+ # accidental key. Enter = 确认 (universal dialog muscle memory);
155
+ # the accepted cost is that double-Enter on a live session confirms
156
+ # its takeover — which is what Enter was asking for anyway.
157
+ if key in ("enter", "y"):
149
158
  cb = self._confirm_yes
150
159
  self._close_confirm()
151
160
  cb()
@@ -160,24 +169,24 @@ class App:
160
169
  self.views[self._active].handle_key(key)
161
170
 
162
171
  def confirm(self, message: str, on_yes: Callable[[], None]) -> None:
163
- """Show a y/n modal over the active tab; run `on_yes` only on `y`.
172
+ """Show a confirm modal over the active tab; run `on_yes` on Enter/y.
164
173
 
165
174
  App-level (not a per-view mode) so every tab gets confirmation for free
166
- and the view files stay under budget. While up, `_input` routes y/n/esc
167
- and swallows the rest. The overlay sits ABOVE the view widget in
175
+ and the view files stay under budget. While up, `_input` routes
176
+ Enter/y/n/esc and swallows the rest. The overlay sits ABOVE the view widget in
168
177
  `self.body`, so a worker-thread refresh (which only rebuilds a view's own
169
178
  walker) never disturbs it.
170
179
  """
171
180
  self._confirm_yes = on_yes
172
181
  self._confirm_base = self.body.original_widget
173
- text = urwid.Text(f" {message}\n\n y 确认 n / Esc 取消")
182
+ text = urwid.Text(f" {message}\n\n Enter / y 确认 n / Esc 取消")
174
183
  box = urwid.AttrMap(urwid.LineBox(urwid.Filler(text)), "notify")
175
184
  self.body.original_widget = urwid.Overlay(
176
185
  box, self._confirm_base,
177
186
  align="center", width=("relative", 50),
178
187
  valign="middle", height=7,
179
188
  )
180
- self.footer_text.set_text(" y 确认 · n/Esc 取消")
189
+ self.footer_text.set_text(" Enter/y 确认 · n/Esc 取消")
181
190
 
182
191
  def _close_confirm(self) -> None:
183
192
  if self._confirm_base is not None:
@@ -196,6 +205,18 @@ class App:
196
205
  def exit_with_resume(self, session: object, fork: bool = False) -> None:
197
206
  self._exit(("resume", session, fork))
198
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))
211
+
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))
219
+
199
220
  def set_hints(self, hints: str) -> None:
200
221
  """Footer = shared prefix + the active tab's keyhints (D1 single source)."""
201
222
  self.footer_text.set_text(FOOTER_PREFIX + hints)
@@ -298,15 +298,38 @@ def _cmd_env(args: argparse.Namespace) -> None:
298
298
 
299
299
 
300
300
  def _cmd_tui(args: argparse.Namespace) -> None:
301
- from .actions.session_ops import do_resume
301
+ from .actions.session_ops import do_resume, do_tmux_new, do_tmux_resume, enter_window
302
302
  from .app import App
303
303
 
304
304
  app = App()
305
305
  result = app.run()
306
306
 
307
- if result and isinstance(result, tuple) and result[0] == "resume":
307
+ if not result or not isinstance(result, tuple):
308
+ return
309
+ if result[0] == "resume":
308
310
  _, session, fork = result
309
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.")
310
333
 
311
334
 
312
335
  def main() -> None:
@@ -16,10 +16,6 @@ class Config:
16
16
  self.rc_list: Path = self.config_dir / "rc-enabled"
17
17
  self.rc_session: str = os.environ.get("CSCTL_RC_SESSION", "rc")
18
18
  self.rc_stagger: int = int(os.environ.get("CSCTL_RC_STAGGER", "2"))
19
- # Dedicated tmux session for interactive sessions relaunched under remote
20
- # control (kept separate from rc_session, whose windows are managed RC
21
- # server processes).
22
- self.tmux_session: str = os.environ.get("CSCTL_TMUX_SESSION", "cc")
23
19
  # Age threshold (days) for the time/global-keyed cleanup strategy.
24
20
  self.cleanup_age_days: int = int(os.environ.get("CSCTL_CLEANUP_AGE_DAYS", "14"))
25
21
  self._workspace: Path | None = None
@@ -11,15 +11,36 @@ from __future__ import annotations
11
11
  import json
12
12
  import subprocess
13
13
  import time
14
+ from collections.abc import Callable
14
15
 
15
16
  from ..models import LiveInfo, SessionProc
17
+ from . import proc
16
18
 
17
19
  _cache: dict[str, int | None] | None = None
18
20
  _cache_time: float = 0.0
19
21
 
20
22
 
23
+ def _scrub_dead_pids(
24
+ mapping: dict[str, int | None],
25
+ exists: Callable[[int | None], bool],
26
+ ) -> dict[str, int | None]:
27
+ """Blank out pids whose process no longer exists (pure; `exists` injected).
28
+
29
+ `claude agents --json` can keep reporting a pid after the worker died
30
+ (SIGKILL/crash before its registry caught up). A dead pid must not count as
31
+ alive — blanking it lets the existing "pid-less entries are not alive" rule
32
+ in `live_index` take over. Entries are kept, never dropped.
33
+ """
34
+ return {sid: (pid if exists(pid) else None) for sid, pid in mapping.items()}
35
+
36
+
21
37
  def alive_map(max_age: float = 5.0) -> dict[str, int | None]:
22
- """Return {session_id: pid} for all known agents. Cached for max_age seconds."""
38
+ """Return {session_id: pid} for all known agents. Cached for max_age seconds.
39
+
40
+ With `/proc` available, pids are scrubbed against process existence at
41
+ cache-refresh time (see `_scrub_dead_pids`). Without `/proc` (R10 degraded
42
+ mode) the map is the ONLY liveness source, so it is passed through as-is.
43
+ """
23
44
  global _cache, _cache_time
24
45
  now = time.monotonic()
25
46
  if _cache is not None and (now - _cache_time) < max_age:
@@ -36,6 +57,8 @@ def alive_map(max_age: float = 5.0) -> dict[str, int | None]:
36
57
  }
37
58
  except Exception:
38
59
  result = {}
60
+ if proc.has_proc():
61
+ result = _scrub_dead_pids(result, proc.pid_exists)
39
62
  _cache = result
40
63
  _cache_time = now
41
64
  return result
@@ -122,19 +145,24 @@ def live_index(
122
145
  )
123
146
 
124
147
  # `claude agents --json` is authoritative for liveness: it covers agent-only
125
- # sids and rescues the degraded (no-/proc) path.
148
+ # sids and rescues the degraded (no-/proc) path. But it also keeps listing
149
+ # settled/blocked bg sessions WITHOUT a pid — those are not alive (there is
150
+ # no process to signal; terminate/stop would always fail), so alive is
151
+ # judged by pid non-empty.
126
152
  for sid, pid in agents_map.items():
127
153
  if not sid:
128
154
  continue
129
155
  info = index.get(sid)
130
156
  if info is None:
131
157
  index[sid] = LiveInfo(
132
- sid=sid, pid=pid, alive=True, pids=[pid] if pid else []
158
+ sid=sid, pid=pid, alive=bool(pid), pids=[pid] if pid else []
133
159
  )
134
160
  continue
161
+ if not pid:
162
+ continue # pid-less entry: the proc-based verdict stands
135
163
  info.alive = True
136
164
  if info.pid is None:
137
165
  info.pid = pid
138
- if pid and pid not in info.pids:
166
+ if pid not in info.pids:
139
167
  info.pids.append(pid)
140
168
  return index
@@ -45,6 +45,19 @@ def current_determinable() -> bool:
45
45
  return has_proc()
46
46
 
47
47
 
48
+ def pid_exists(pid: int | None) -> bool:
49
+ """Bare `/proc/<pid>` existence — no starttime / pid-reuse defense.
50
+
51
+ For scrubbing externally-reported pids (e.g. `claude agents --json`) where
52
+ no recorded procStart exists to compare against. Deliberately does NOT
53
+ check comm/cmdline (a claude process may exec under another name). Always
54
+ False without `/proc`, so callers must skip scrubbing in degraded mode.
55
+ """
56
+ if not pid:
57
+ return False
58
+ return os.path.isdir(f"{_PROC}/{pid}")
59
+
60
+
48
61
  def proc_starttime(pid: int) -> str | None:
49
62
  """Field 22 (starttime) from `/proc/<pid>/stat`, or None if unavailable.
50
63
 
@@ -85,18 +98,13 @@ def pid_alive(pid: int | None, proc_start: str | None) -> bool:
85
98
  return st == proc_start
86
99
 
87
100
 
88
- def ancestor_pids() -> set[int]:
89
- """csctl's own ancestor pid chain (including self).
90
-
91
- A session whose pid is in this set is the "current" one (it launched
92
- csctl) and is protected. Linux/WSL only — returns just `{getpid()}` when
93
- `/proc` is unavailable, in which case current can't be determined and
94
- callers must degrade (see R10).
95
- """
96
- pids = {os.getpid()}
101
+ def ancestors_of(start_pid: int) -> set[int]:
102
+ """Ancestor pid chain of `start_pid` (including itself), via `/proc` ppid
103
+ walk. Returns just `{start_pid}` when `/proc` is unavailable."""
104
+ pids = {start_pid}
97
105
  if not has_proc():
98
106
  return pids
99
- pid = os.getpid()
107
+ pid = start_pid
100
108
  for _ in range(40):
101
109
  try:
102
110
  with open(f"{_PROC}/{pid}/stat") as fh:
@@ -111,6 +119,17 @@ def ancestor_pids() -> set[int]:
111
119
  return pids
112
120
 
113
121
 
122
+ def ancestor_pids() -> set[int]:
123
+ """csctl's own ancestor pid chain (including self).
124
+
125
+ A session whose pid is in this set is the "current" one (it launched
126
+ csctl) and is protected. Linux/WSL only — returns just `{getpid()}` when
127
+ `/proc` is unavailable, in which case current can't be determined and
128
+ callers must degrade (see R10).
129
+ """
130
+ return ancestors_of(os.getpid())
131
+
132
+
114
133
  # --- project RC server discovery (R5 / D5) ---------------------------------
115
134
  # A real `claude remote-control --name <name>` server's /proc cmdline shows the
116
135
  # FULL argv (verified live: a bare interactive `claude` instead collapses its
@@ -175,14 +175,27 @@ def _tmux_has_session(session: str) -> bool:
175
175
  return cp is not None and cp.returncode == 0
176
176
 
177
177
 
178
- def _tmux_new_window(session: str, name: str, cmd: str) -> bool:
179
- cp = _tmux_run(["new-window", "-t", session, "-n", name, cmd])
180
- return cp is not None and cp.returncode == 0
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
181
 
182
182
 
183
- def _tmux_new_session(session: str, name: str, cmd: str) -> bool:
184
- cp = _tmux_run(["new-session", "-d", "-s", session, "-n", name, cmd])
185
- return cp is not None and cp.returncode == 0
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)
186
199
 
187
200
 
188
201
  def _tmux_kill_window(target: str) -> bool:
@@ -203,15 +216,85 @@ def _is_alive(proj: str) -> bool:
203
216
  return _tmux_pane_alive(f"{cfg.rc_session}:{proj}")
204
217
 
205
218
 
206
- def run_in_tmux(session: str, window: str, cmd: str) -> bool:
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:
207
231
  """Run `cmd` in a tmux `window` under `session`, creating the session if
208
- it doesn't exist yet. Public seam for relaunching a session outside the
209
- managed RC server machinery."""
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."""
210
236
  if _tmux_has_session(session):
211
237
  return _tmux_new_window(session, window, cmd)
212
238
  return _tmux_new_session(session, window, cmd)
213
239
 
214
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
+
215
298
  def _read_rc_at_startup(directory: str) -> bool | None:
216
299
  for name in ("settings.local.json", "settings.json"):
217
300
  path = os.path.join(directory, ".claude", name)
@@ -380,12 +463,7 @@ def start_one(proj: str) -> bool:
380
463
  f"--name {shlex.quote(remote_name)} --spawn same-dir"
381
464
  )
382
465
 
383
- session = cfg.rc_session
384
- has_session = _tmux_has_session(session)
385
-
386
- if has_session:
387
- return _tmux_new_window(session, proj, cmd)
388
- return _tmux_new_session(session, proj, cmd)
466
+ return run_in_tmux(cfg.rc_session, proj, cmd) is not None
389
467
 
390
468
 
391
469
  def stop_one(proj: str) -> bool:
@@ -12,7 +12,7 @@ description: Diagnose and rescue local Claude Code sessions via csctl — list l
12
12
  ## 交互式 TUI
13
13
 
14
14
  ```bash
15
- csctl # 三个 tab:会话 / 后台 agent / 远程控制;清理在会话 tab 的子菜单
15
+ csctl # 三个 tab:会话 / 项目(远控 + t 新建 tmux 会话)/ 后台 agent;清理在会话 tab 的子菜单
16
16
  ```
17
17
 
18
18
  会话 tab 键位:方向键移动 · `/` 过滤 · `Enter` 接回(真接续,自动 cd+kill)· `f` 分叉接回 · `y` 复制命令到剪贴板 · `t` 终止活会话(确认) · `r` 刷新 · `q` 退出。接回时 csctl exec 成 claude 接管终端。