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.
- {cc_session_control-0.5.0/src/cc_session_control.egg-info → cc_session_control-0.6.0}/PKG-INFO +2 -2
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/README.md +1 -1
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/agent_ops.py +5 -4
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/session_ops.py +77 -2
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/app.py +45 -24
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/cli.py +25 -2
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/config.py +0 -4
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/liveness.py +32 -4
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/proc.py +29 -10
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/rc.py +93 -15
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/skill/SKILL.md +1 -1
- cc_session_control-0.6.0/src/cc_session_control/views/_colspec.py +49 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/_session_row.py +46 -25
- cc_session_control-0.6.0/src/cc_session_control/views/_sessions_cleanup.py +163 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/agents.py +23 -17
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/rc.py +68 -131
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/sessions.py +48 -155
- {cc_session_control-0.5.0 → cc_session_control-0.6.0/src/cc_session_control.egg-info}/PKG-INFO +2 -2
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/SOURCES.txt +2 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_agent_ops.py +3 -2
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_agents.py +1 -1
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_app.py +14 -15
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_data.py +138 -8
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_liveness.py +79 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_views.py +171 -70
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/LICENSE +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/pyproject.toml +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/setup.cfg +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/agents.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/cleanup.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/environments.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/registry.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/sessions.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/snapshot.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/models.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_cleanup.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_cli.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_environments.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_proc.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_rc.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_registry.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_sessions.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_skill_ops.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_smoke.py +0 -0
- {cc_session_control-0.5.0 → cc_session_control-0.6.0}/tests/test_snapshot.py +0 -0
{cc_session_control-0.5.0/src/cc_session_control.egg-info → cc_session_control-0.6.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cc-session-control
|
|
3
|
-
Version: 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
|
-
- **
|
|
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
|
-
- **
|
|
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/).
|
{cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/agent_ops.py
RENAMED
|
@@ -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
|
|
101
|
-
outlives the terminal — it does NOT
|
|
102
|
-
returned string also feeds the
|
|
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(
|
|
106
|
+
rc.run_in_tmux(rc.session_name_for(job.cwd), _job_window(job), cmd)
|
|
106
107
|
return cmd
|
|
107
108
|
|
|
108
109
|
|
{cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/actions/session_ops.py
RENAMED
|
@@ -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
|
-
|
|
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",
|
|
48
|
-
("footer",
|
|
49
|
-
("tab_on",
|
|
50
|
-
("tab_off",
|
|
51
|
-
("alive",
|
|
52
|
-
("
|
|
53
|
-
("
|
|
54
|
-
("
|
|
55
|
-
("
|
|
56
|
-
("
|
|
57
|
-
("
|
|
58
|
-
("body",
|
|
59
|
-
("col_header",
|
|
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
|
|
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
|
-
|
|
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
|
|
147
|
-
# a destructive confirm can't be skipped past by an
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
{cc_session_control-0.5.0 → cc_session_control-0.6.0}/src/cc_session_control/data/liveness.py
RENAMED
|
@@ -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=
|
|
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
|
|
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
|
|
89
|
-
"""
|
|
90
|
-
|
|
91
|
-
|
|
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 =
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
184
|
-
cp
|
|
185
|
-
|
|
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
|
|
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.
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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 接管终端。
|