cc-session-control 0.6.5__tar.gz → 0.7.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.6.5/src/cc_session_control.egg-info → cc_session_control-0.7.0}/PKG-INFO +3 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/README.md +2 -2
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py +5 -1
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py +38 -54
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/app.py +6 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/sessions.py +39 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/tmux.py +27 -7
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/models.py +5 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/skill/SKILL.md +3 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_confirm.py +36 -1
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_session_row.py +10 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/agents.py +22 -4
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/rc.py +9 -6
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/sessions.py +34 -42
- {cc_session_control-0.6.5 → cc_session_control-0.7.0/src/cc_session_control.egg-info}/PKG-INFO +3 -3
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_agent_ops.py +6 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_agents.py +55 -24
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_app.py +26 -9
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_data.py +72 -88
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_sessions.py +45 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_skill_ops.py +6 -4
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_views.py +122 -33
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/LICENSE +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/pyproject.toml +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/setup.cfg +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/cli.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/cleanup.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/environments.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/liveness.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/rc.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/registry.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/snapshot.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_base.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_keytable.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/SOURCES.txt +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_cleanup.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_cli.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_environments.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_liveness.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_rc.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_registry.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_smoke.py +0 -0
- {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_snapshot.py +0 -0
{cc_session_control-0.6.5/src/cc_session_control.egg-info → cc_session_control-0.7.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.7.0
|
|
4
4
|
Summary: TUI manager for Claude Code sessions and Remote Control
|
|
5
5
|
Author: dzshzx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -29,8 +29,8 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
|
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- **Sessions Tab** — View, resume (bare
|
|
33
|
-
- **Projects Tab** —
|
|
32
|
+
- **Sessions Tab** — View, resume (tmux-first: `Enter` resumes into the per-project tmux window; `t` bare-terminal fallback; `R` backgrounds into tmux; ⧉ marks tmux-resident sessions), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
|
|
33
|
+
- **Projects Tab** — The startup tab: start a new tmux claude session in a project dir (`Enter`), start/stop RC servers per project (`o`/`s`), toggle auto-start, show running/stopped/dead states
|
|
34
34
|
- **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
|
|
35
35
|
|
|
36
36
|
Built with [urwid](https://urwid.org/).
|
|
@@ -6,8 +6,8 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Sessions Tab** — View, resume (bare
|
|
10
|
-
- **Projects Tab** —
|
|
9
|
+
- **Sessions Tab** — View, resume (tmux-first: `Enter` resumes into the per-project tmux window; `t` bare-terminal fallback; `R` backgrounds into tmux; ⧉ marks tmux-resident sessions), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
|
|
10
|
+
- **Projects Tab** — The startup tab: start a new tmux claude session in a project dir (`Enter`), start/stop RC servers per project (`o`/`s`), toggle auto-start, show running/stopped/dead states
|
|
11
11
|
- **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
|
|
12
12
|
|
|
13
13
|
Built with [urwid](https://urwid.org/).
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py
RENAMED
|
@@ -120,7 +120,10 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
120
120
|
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
121
121
|
`pid`/`alive` come from the host join so a live worker is killed first
|
|
122
122
|
(resume = takeover); `current` is computed so the launching session stays
|
|
123
|
-
protected.
|
|
123
|
+
protected. `tmux_target` is filled here at action time (the agents list
|
|
124
|
+
renders no ⧉ badge, so there is no batch snapshot value to reuse) so the
|
|
125
|
+
tmux-first Enter can enter a resident worker in place. Does NOT itself
|
|
126
|
+
replace the csctl process.
|
|
124
127
|
"""
|
|
125
128
|
pid, alive = job_host(job)
|
|
126
129
|
current = bool(pid) and pid in proc.ancestor_pids()
|
|
@@ -136,6 +139,7 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
136
139
|
proc_start=_host_start(pid),
|
|
137
140
|
source="bg",
|
|
138
141
|
agent_short=job.short,
|
|
142
|
+
tmux_target=tmux.find_session_window([pid]) if alive and pid else None,
|
|
139
143
|
)
|
|
140
144
|
|
|
141
145
|
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py
RENAMED
|
@@ -87,7 +87,7 @@ def would_take_over(s: Session, fork: bool = False) -> bool:
|
|
|
87
87
|
The single source of the "needs confirmation" decision for the UI: it reads
|
|
88
88
|
`_resume_plan`'s `should_kill` so views never re-derive `s.alive and not
|
|
89
89
|
s.current` themselves (CLAUDE.md: should_kill is single-point — re-derivation
|
|
90
|
-
was the old divergence). `do_resume`/`
|
|
90
|
+
was the old divergence). `do_resume`/`do_tmux_resume` and the confirm gate
|
|
91
91
|
thus agree by construction.
|
|
92
92
|
"""
|
|
93
93
|
return _resume_plan(s, fork)[2]
|
|
@@ -126,79 +126,60 @@ def do_resume(s: Session, fork: bool = False) -> None:
|
|
|
126
126
|
os.execvp("claude", args)
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
def _rc_name(s: Session) -> str:
|
|
130
|
-
"""Remote-control label (shown in claude.ai/code) for a relaunched session."""
|
|
131
|
-
base = s.cwd.rstrip("/").rsplit("/", 1)[-1] if s.cwd else ""
|
|
132
|
-
return f"{base or 'session'}-{s.sid[:8]}"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
|
|
136
|
-
"""Shell command that resumes the session under remote control."""
|
|
137
|
-
cwd, args, _ = _resume_plan(s, fork)
|
|
138
|
-
args = args + ["--remote-control", _rc_name(s)]
|
|
139
|
-
line = shlex.join(args)
|
|
140
|
-
return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
|
|
141
|
-
|
|
142
|
-
|
|
143
129
|
def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
|
|
144
130
|
"""Kill-if-takeover, then spawn `cmd` in the session's per-project tmux
|
|
145
|
-
session — the shared skeleton behind `
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
or None on R10 refusal ("refused" from
|
|
149
|
-
"gone"/"failed" fall through like
|
|
131
|
+
session — the shared skeleton behind `do_tmux_resume` (Enter/f/R, the
|
|
132
|
+
tmux-first dispatch verbs, ADR-0001). A fork is a copy (never kills) and
|
|
133
|
+
gets its own `<sid8>-fork` window so it doesn't shadow the original's.
|
|
134
|
+
Returns the exact tmux target, or None on R10 refusal ("refused" from
|
|
135
|
+
`take_over`) / tmux failure; "gone"/"failed" fall through like
|
|
136
|
+
`do_resume` (best-effort kill).
|
|
150
137
|
"""
|
|
151
138
|
_, _, should_kill = _resume_plan(s, fork)
|
|
152
139
|
if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
|
|
153
140
|
return None
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
|
|
158
|
-
"""Relaunch a session as `claude --resume … --remote-control …` inside a
|
|
159
|
-
tmux window, so it outlives the terminal and is remotely controllable.
|
|
160
|
-
|
|
161
|
-
A live, non-current session is taken over (old pid killed first, like
|
|
162
|
-
terminate); a fork leaves the original running. csctl is NOT replaced —
|
|
163
|
-
it just spawns the tmux window.
|
|
164
|
-
"""
|
|
165
|
-
return _spawn_in_tmux(s, tmux_resume_cmd(s, fork), fork=fork) is not None
|
|
141
|
+
window = f"{s.sid[:8]}-fork" if fork else s.sid[:8]
|
|
142
|
+
return tmux.run_in_tmux(tmux.session_name_for(s.cwd), window, cmd)
|
|
166
143
|
|
|
167
144
|
|
|
168
145
|
def attach_target(s: Session) -> str | None:
|
|
169
146
|
"""The tmux window ("session:index") already hosting this live session.
|
|
170
147
|
|
|
171
|
-
Non-None means the
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
148
|
+
Non-None means the session is tmux-resident and can be entered in place —
|
|
149
|
+
no kill, no respawn. Reads the snapshot-computed `Session.tmux_target`
|
|
150
|
+
(the SAME data the ⧉ badge renders — one source, no per-action
|
|
151
|
+
re-detection), guarded on `alive` so a stale target on a dead session
|
|
152
|
+
never answers."""
|
|
153
|
+
if not s.alive:
|
|
175
154
|
return None
|
|
176
|
-
return
|
|
155
|
+
return s.tmux_target
|
|
177
156
|
|
|
178
157
|
|
|
179
|
-
def tmux_foreground_cmd(s: Session) -> str:
|
|
180
|
-
"""Shell command for
|
|
158
|
+
def tmux_foreground_cmd(s: Session, fork: bool = False) -> str:
|
|
159
|
+
"""Shell command for a tmux-dispatch window: plain resume, NO
|
|
160
|
+
--remote-control.
|
|
181
161
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cwd, args, _ = _resume_plan(s)
|
|
162
|
+
Deliberate (ADR-0001): tmux residency is the anti-disconnect mechanism;
|
|
163
|
+
every --remote-control process mints a new cloud environment entry, which
|
|
164
|
+
piles up with frequent use — the Sessions tab never mints cloud envs."""
|
|
165
|
+
cwd, args, _ = _resume_plan(s, fork)
|
|
186
166
|
line = shlex.join(args)
|
|
187
167
|
return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
|
|
188
168
|
|
|
189
169
|
|
|
190
|
-
def do_tmux_resume(s: Session) -> str | None:
|
|
191
|
-
"""Kill-if-takeover, spawn the resume window in the
|
|
192
|
-
tmux session, and return the exact tmux target
|
|
193
|
-
|
|
194
|
-
|
|
170
|
+
def do_tmux_resume(s: Session, fork: bool = False) -> str | None:
|
|
171
|
+
"""Kill-if-takeover (fork never kills), spawn the resume window in the
|
|
172
|
+
session's per-project tmux session, and return the exact tmux target;
|
|
173
|
+
None on failure. Enter/f enter the target afterwards (`TmuxResumeIntent`);
|
|
174
|
+
R 转后台 spawns it and stays in csctl."""
|
|
175
|
+
return _spawn_in_tmux(s, tmux_foreground_cmd(s, fork), fork=fork)
|
|
195
176
|
|
|
196
177
|
|
|
197
178
|
def do_tmux_new(directory: str) -> str | None:
|
|
198
179
|
"""Start a NEW claude session in `directory`, inside that project's own
|
|
199
180
|
tmux session, and return the exact tmux target to enter; None on failure.
|
|
200
181
|
|
|
201
|
-
The 项目-tab
|
|
182
|
+
The 项目-tab Enter key: same skeleton as `do_tmux_resume` but nothing exists
|
|
202
183
|
yet — no kill, no confirm, no R10 gate (no process is terminated). Plain
|
|
203
184
|
`claude` with NO --remote-control (same tradeoff as `tmux_foreground_cmd`:
|
|
204
185
|
every RC process mints a new cloud environment entry). No trust gate
|
|
@@ -226,7 +207,8 @@ class ExitIntent:
|
|
|
226
207
|
|
|
227
208
|
@dataclass(frozen=True)
|
|
228
209
|
class ResumeIntent(ExitIntent):
|
|
229
|
-
"""
|
|
210
|
+
"""`t` 终端接回: bare-terminal resume (execvp; takeover kill inside
|
|
211
|
+
do_resume) — the fallback when tmux is unavailable or unwanted."""
|
|
230
212
|
|
|
231
213
|
session: Session
|
|
232
214
|
fork: bool = False
|
|
@@ -237,7 +219,7 @@ class ResumeIntent(ExitIntent):
|
|
|
237
219
|
|
|
238
220
|
@dataclass(frozen=True)
|
|
239
221
|
class AttachIntent(ExitIntent):
|
|
240
|
-
"""
|
|
222
|
+
"""Enter on a tmux-resident session: enter its window in place (no kill)."""
|
|
241
223
|
|
|
242
224
|
target: str
|
|
243
225
|
|
|
@@ -248,12 +230,14 @@ class AttachIntent(ExitIntent):
|
|
|
248
230
|
|
|
249
231
|
@dataclass(frozen=True)
|
|
250
232
|
class TmuxResumeIntent(ExitIntent):
|
|
251
|
-
"""
|
|
233
|
+
"""Enter/f on a dead / bare-terminal session: resume (or fork) inside
|
|
234
|
+
tmux, then enter — the primary tmux-first dispatch (ADR-0001)."""
|
|
252
235
|
|
|
253
236
|
session: Session
|
|
237
|
+
fork: bool = False
|
|
254
238
|
|
|
255
239
|
def run(self) -> None:
|
|
256
|
-
target = do_tmux_resume(self.session)
|
|
240
|
+
target = do_tmux_resume(self.session, fork=self.fork)
|
|
257
241
|
if target is None:
|
|
258
242
|
print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
|
|
259
243
|
elif not enter_window(target):
|
|
@@ -262,7 +246,7 @@ class TmuxResumeIntent(ExitIntent):
|
|
|
262
246
|
|
|
263
247
|
@dataclass(frozen=True)
|
|
264
248
|
class TmuxNewIntent(ExitIntent):
|
|
265
|
-
"""项目-tab
|
|
249
|
+
"""项目-tab Enter: start a NEW claude in tmux, then enter (pure spawn)."""
|
|
266
250
|
|
|
267
251
|
directory: str
|
|
268
252
|
|
|
@@ -69,7 +69,9 @@ PALETTE = [
|
|
|
69
69
|
("col_header", "dark cyan", "black", None, "#9cc", "#181818"),
|
|
70
70
|
]
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
# Launcher-first order (ADR-0001): startup lands on 项目 (the tmux-first
|
|
73
|
+
# dispatch entry), then 会话 / 后台. In lockstep with `self.views` below.
|
|
74
|
+
TAB_NAMES = ["项目", "会话", "后台"]
|
|
73
75
|
|
|
74
76
|
# D1: all three tabs share ONE footer prefix — the universal verbs (Tab/q/r) live
|
|
75
77
|
# here exactly once so `r 刷新` shows identically on every tab. View-specific keys
|
|
@@ -104,8 +106,9 @@ class App:
|
|
|
104
106
|
self._confirm_yes: Callable[[], None] | None = None
|
|
105
107
|
self._confirm_base: urwid.Widget | None = None
|
|
106
108
|
|
|
107
|
-
# Order is in lockstep with TAB_NAMES:
|
|
108
|
-
|
|
109
|
+
# Order is in lockstep with TAB_NAMES: 项目 / 会话 / 后台 (launcher-first,
|
|
110
|
+
# ADR-0001 — startup loads the 项目 tab at _active=0).
|
|
111
|
+
self.views: list[TabView] = [RCView(self), SessionsView(self), AgentsView(self)]
|
|
109
112
|
self._active = 0
|
|
110
113
|
|
|
111
114
|
self.body = urwid.WidgetPlaceholder(self.views[0].widget)
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/sessions.py
RENAMED
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
|
|
9
9
|
from ..config import cfg
|
|
10
10
|
from ..models import LiveInfo, Session
|
|
11
|
-
from . import registry
|
|
11
|
+
from . import registry, tmux
|
|
12
12
|
from .liveness import alive_map, is_rc_exposed, live_index, live_session_procs
|
|
13
13
|
from .proc import ancestor_pids as _ancestor_pids # /proc walk moved to proc.py
|
|
14
14
|
|
|
@@ -160,14 +160,49 @@ def _parse_transcript(
|
|
|
160
160
|
)
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def _candidate_pids(info: LiveInfo | None) -> list[int]:
|
|
164
|
+
"""A LiveInfo's pid candidate set — `pids` when filled, else the chosen pid
|
|
165
|
+
(same fallback rule the `current` check in `_parse_transcript` uses)."""
|
|
166
|
+
if info is None:
|
|
167
|
+
return []
|
|
168
|
+
if info.pids:
|
|
169
|
+
return info.pids
|
|
170
|
+
return [info.pid] if info.pid else []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _inject_tmux_residency(rows: list[Session], idx: dict[str, LiveInfo]) -> None:
|
|
174
|
+
"""Fill `Session.tmux_target` for every ALIVE session, in ONE batch.
|
|
175
|
+
|
|
176
|
+
Collects all alive sessions' candidate pids, calls
|
|
177
|
+
`tmux.residency_targets` once (one `list-panes -a` per scan cycle), and
|
|
178
|
+
backfills each session with its first hit — any alive pid inside a tmux
|
|
179
|
+
pane makes the session resident (ADR-0001). Dead sessions stay None; the
|
|
180
|
+
badge and the resume/backgrounding actions read this SAME field, so there
|
|
181
|
+
is no per-action re-detection (no second source of truth)."""
|
|
182
|
+
alive_pids = {
|
|
183
|
+
pid for row in rows if row.alive for pid in _candidate_pids(idx.get(row.sid))
|
|
184
|
+
}
|
|
185
|
+
targets = tmux.residency_targets(alive_pids)
|
|
186
|
+
if not targets:
|
|
187
|
+
return
|
|
188
|
+
for row in rows:
|
|
189
|
+
if not row.alive:
|
|
190
|
+
continue
|
|
191
|
+
for pid in _candidate_pids(idx.get(row.sid)):
|
|
192
|
+
if pid in targets:
|
|
193
|
+
row.tmux_target = targets[pid]
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
|
|
163
197
|
def scan() -> list[Session]:
|
|
164
198
|
"""Unified transcript-driven session scan.
|
|
165
199
|
|
|
166
200
|
Merges the three liveness/identity sources once per scan — registry
|
|
167
201
|
`sessions/<pid>.json`, `claude agents --json`, and `jobs/*/state.json` — then
|
|
168
202
|
projects each transcript through `live_index()` to fill source/liveness/
|
|
169
|
-
rc-exposure
|
|
170
|
-
|
|
203
|
+
rc-exposure, and batch-injects tmux residency (`tmux_target`). Scan stays
|
|
204
|
+
transcript-driven: an agent-only sid (present in the live index but with no
|
|
205
|
+
transcript) is surfaced by the Agents tab, not here.
|
|
171
206
|
"""
|
|
172
207
|
root = str(cfg.projects_root)
|
|
173
208
|
session_procs = live_session_procs()
|
|
@@ -182,5 +217,6 @@ def scan() -> list[Session]:
|
|
|
182
217
|
if row is not None:
|
|
183
218
|
rows.append(row)
|
|
184
219
|
|
|
220
|
+
_inject_tmux_residency(rows, idx)
|
|
185
221
|
rows.sort(key=lambda r: r.mtime, reverse=True)
|
|
186
222
|
return rows
|
|
@@ -16,6 +16,7 @@ don't conflate tmux windows with Remote Control).
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import subprocess
|
|
19
|
+
from collections.abc import Iterable
|
|
19
20
|
|
|
20
21
|
from . import proc
|
|
21
22
|
|
|
@@ -170,18 +171,37 @@ def window_containing(
|
|
|
170
171
|
return None
|
|
171
172
|
|
|
172
173
|
|
|
173
|
-
def
|
|
174
|
-
"""
|
|
174
|
+
def residency_targets(pids: Iterable[int]) -> dict[int, str]:
|
|
175
|
+
"""{pid: "session:window_index"} for every pid hosted by a tmux pane.
|
|
175
176
|
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
THE batch tmux-residency computation (ADR-0001 badge + actions share it):
|
|
178
|
+
ONE `list-panes -a` for the whole pid set, then each pid's `/proc` ancestor
|
|
179
|
+
chain is matched against the pane root pids — finds windows in ANY tmux
|
|
180
|
+
session (per-project, rc, user-made). Misses are simply absent from the
|
|
181
|
+
dict; tmux failure returns {} (swallow-errors contract)."""
|
|
182
|
+
pid_list = list(pids)
|
|
183
|
+
if not pid_list:
|
|
184
|
+
return {}
|
|
178
185
|
panes = _tmux_list_all_panes()
|
|
179
186
|
if not panes:
|
|
180
|
-
return
|
|
181
|
-
|
|
187
|
+
return {}
|
|
188
|
+
out: dict[int, str] = {}
|
|
189
|
+
for pid in pid_list:
|
|
182
190
|
target = window_containing(panes, proc.ancestors_of(pid))
|
|
183
191
|
if target:
|
|
184
|
-
|
|
192
|
+
out[pid] = target
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_session_window(pids: list[int]) -> str | None:
|
|
197
|
+
"""The tmux window ("session:index") hosting any of `pids`, or None.
|
|
198
|
+
|
|
199
|
+
Single-target convenience over `residency_targets` (first hit in `pids`
|
|
200
|
+
order) — used by the action layer when only one session is in play."""
|
|
201
|
+
targets = residency_targets(pids)
|
|
202
|
+
for pid in pids:
|
|
203
|
+
if pid in targets:
|
|
204
|
+
return targets[pid]
|
|
185
205
|
return None
|
|
186
206
|
|
|
187
207
|
|
|
@@ -34,6 +34,11 @@ class Session:
|
|
|
34
34
|
env_id: str | None = None # bound bridge environment id, if any
|
|
35
35
|
agent_short: str | None = None # linked background-agent short id, if any
|
|
36
36
|
status: str = "" # registry `status` (busy / idle)
|
|
37
|
+
# tmux residency (CONTEXT.md / ADR-0001): non-None means a live pid of this
|
|
38
|
+
# session runs inside a tmux pane; the value is the enterable
|
|
39
|
+
# "session:window_index" target. Batch-computed in sessions.scan() via
|
|
40
|
+
# tmux.residency_targets — actions and the ⧉ badge read the SAME field.
|
|
41
|
+
tmux_target: str | None = None
|
|
37
42
|
|
|
38
43
|
@property
|
|
39
44
|
def bridge_or_sdk(self) -> bool:
|
|
@@ -12,10 +12,10 @@ description: Diagnose and rescue local Claude Code sessions via csctl — list l
|
|
|
12
12
|
## 交互式 TUI
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
csctl # 三个 tab
|
|
15
|
+
csctl # 三个 tab(tmux-first,启动落在项目 tab):项目(Enter 新建 tmux 会话 / o 启动远控)/ 会话 / 后台 agent;清理在会话 tab 的子菜单
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
会话 tab 键位:方向键移动 · `/` 过滤 · `Enter`
|
|
18
|
+
会话 tab 键位:方向键移动 · `/` 过滤 · `Enter` tmux 接回(主操作:恢复进 per-project tmux 窗口并进入,断线不死;已驻留 ⧉ 会话就地进入;接活会话先确认)· `t` 终端接回(裸终端兜底,真接续自动 cd+kill)· `f` 分叉进 tmux · `s` 停止活会话(确认) · `R` 转后台(进 tmux 不进入、不开远控,留在 csctl)· `d` 删除已结束会话 · `y` 复制命令到剪贴板 · `h` 桥接/SDK 显隐 · `c` 清理子菜单 · `r` 刷新 · `q` 退出。接回 = 离开 csctl(attach 进 tmux 或 exec 成 claude)。
|
|
19
19
|
|
|
20
20
|
## 心智模型:会话形态
|
|
21
21
|
|
|
@@ -63,7 +63,7 @@ csctl resume [关键词] --all # 不分页全列
|
|
|
63
63
|
|
|
64
64
|
## 接管远程控制 / 网页会话到本地
|
|
65
65
|
|
|
66
|
-
`claude --remote-control` 或 claude.ai 网页控制的会话,在本机就是一个普通的**活会话**(会进 `claude agents`)。想搬到本地终端接管,照「接回规则」当"还活着"处理即可;`csctl resume` 已自动给出对应命令。TUI
|
|
66
|
+
`claude --remote-control` 或 claude.ai 网页控制的会话,在本机就是一个普通的**活会话**(会进 `claude agents`)。想搬到本地终端接管,照「接回规则」当"还活着"处理即可;`csctl resume` 已自动给出对应命令。TUI 里选中后 `Enter` tmux 接回(或 `t` 终端接回)。注意 0.7 起会话 tab **不再提供带 `--remote-control` 的 relaunch**(每个远控进程都会新铸一个云端环境);要恢复远控暴露,用项目 tab 的 `o`/`c` 或会话内 `/remote-control`。
|
|
67
67
|
|
|
68
68
|
## 清理残留 bg agent
|
|
69
69
|
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_confirm.py
RENAMED
|
@@ -12,7 +12,12 @@ from __future__ import annotations
|
|
|
12
12
|
from collections.abc import Callable
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
|
|
15
|
-
from ..actions.session_ops import
|
|
15
|
+
from ..actions.session_ops import (
|
|
16
|
+
AttachIntent,
|
|
17
|
+
TmuxResumeIntent,
|
|
18
|
+
attach_target,
|
|
19
|
+
would_take_over,
|
|
20
|
+
)
|
|
16
21
|
from ..data import proc
|
|
17
22
|
from ._rows import CONFIRM_NAME_CELLS, truncate_cells
|
|
18
23
|
|
|
@@ -89,3 +94,33 @@ def confirm_takeover(
|
|
|
89
94
|
return
|
|
90
95
|
shown = truncate_cells(s.label if name is None else name, CONFIRM_NAME_CELLS)
|
|
91
96
|
app.confirm(f"{verb}「{shown}」?将先终止原进程。", on_yes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def confirm_tmux_takeover(
|
|
100
|
+
app: App,
|
|
101
|
+
s: Session,
|
|
102
|
+
verb: str,
|
|
103
|
+
*,
|
|
104
|
+
fork: bool = False,
|
|
105
|
+
name: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""The tmux-first Enter/f body, shared by the 会话/后台 tabs (ADR-0001).
|
|
108
|
+
|
|
109
|
+
A tmux-resident session is entered IN PLACE (`AttachIntent` — no kill, no
|
|
110
|
+
confirm, no R10 gate: nothing destructive happens). Anything else goes
|
|
111
|
+
through the standard takeover gate (`confirm_takeover`) into
|
|
112
|
+
`TmuxResumeIntent` — resume (or fork) inside its per-project tmux window,
|
|
113
|
+
then enter. A fork is a copy: it never enters the original's window in
|
|
114
|
+
place, it always spawns its own (and never kills, so the confirm path
|
|
115
|
+
falls straight through).
|
|
116
|
+
"""
|
|
117
|
+
if not fork:
|
|
118
|
+
target = attach_target(s)
|
|
119
|
+
if target:
|
|
120
|
+
app.exit_with(AttachIntent(target))
|
|
121
|
+
return
|
|
122
|
+
confirm_takeover(
|
|
123
|
+
app, s, verb,
|
|
124
|
+
lambda: app.exit_with(TmuxResumeIntent(s, fork=fork)),
|
|
125
|
+
name=name, fork=fork,
|
|
126
|
+
)
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_session_row.py
RENAMED
|
@@ -34,7 +34,7 @@ _SOURCE_BADGES = {
|
|
|
34
34
|
# left; the numeric 提问 and the ragged relative 时间 right-align so their line-
|
|
35
35
|
# to-line anchor is stable.
|
|
36
36
|
SESSION_COLS = [
|
|
37
|
-
(
|
|
37
|
+
(8, "left", "状态"),
|
|
38
38
|
(4, "left", "来源"),
|
|
39
39
|
(4, "left", "远控"),
|
|
40
40
|
(11, "right", "时间"),
|
|
@@ -74,11 +74,18 @@ def _status_parts(session: Session) -> tuple[str, str]:
|
|
|
74
74
|
meaning (忙 = generating/tool-running, 闲 = waiting for input, 停 = no
|
|
75
75
|
process), the ●/○ shape survives colorless terminals, and only the
|
|
76
76
|
established ●=on / ○=off convention is used (no ◐ — ambiguous, and an
|
|
77
|
-
East-Asian-Ambiguous width risk, the P5 glyph lesson).
|
|
77
|
+
East-Asian-Ambiguous width risk, the P5 glyph lesson).
|
|
78
|
+
|
|
79
|
+
A live tmux-resident session (ADR-0001) additionally shows the ⧉ badge —
|
|
80
|
+
U+29C9 is East_Asian_Width=Neutral (width-stable 1 cell, verified against
|
|
81
|
+
wcwidth + urwid.calc_width, the P5 check), unlike the ambiguous glyphs the
|
|
82
|
+
P5 lesson banned. Data comes from the snapshot's `tmux_target`; the resume
|
|
83
|
+
actions read the SAME field."""
|
|
78
84
|
cur = "▸" if session.current else " "
|
|
79
85
|
if session.alive:
|
|
80
86
|
word = "忙" if session.status == "busy" else "闲"
|
|
81
|
-
|
|
87
|
+
badge = " ⧉" if session.tmux_target else ""
|
|
88
|
+
return f"{cur}● {word}{badge}", ("status_busy" if word == "忙" else "alive")
|
|
82
89
|
return f"{cur}○ 停", "dead"
|
|
83
90
|
|
|
84
91
|
|
{cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/agents.py
RENAMED
|
@@ -21,7 +21,7 @@ from ..models import AgentJob
|
|
|
21
21
|
from ._base import ListTabView
|
|
22
22
|
from ._colspec import header_columns, row_columns
|
|
23
23
|
from ._confirm import DEGRADED as _DEGRADED
|
|
24
|
-
from ._confirm import confirm_stop, confirm_takeover
|
|
24
|
+
from ._confirm import confirm_stop, confirm_takeover, confirm_tmux_takeover
|
|
25
25
|
from ._keytable import HelpLayout, Key, footer_hints, help_lines
|
|
26
26
|
from ._rows import TextRow
|
|
27
27
|
|
|
@@ -77,9 +77,15 @@ class AgentsView(ListTabView):
|
|
|
77
77
|
# help, and dispatch are generated from this table. `r 刷新` stays in the
|
|
78
78
|
# App-level FOOTER_PREFIX, so its entry is hint-less.
|
|
79
79
|
KEY_TABLE = (
|
|
80
|
-
Key(("enter",
|
|
80
|
+
Key(("enter",), "Enter 接回", "_takeover",
|
|
81
81
|
section="后台 agent 生命周期:", help_lines=(
|
|
82
|
-
" Enter
|
|
82
|
+
" Enter tmux 接回(恢复进 tmux 窗口并接入前台,断线不死;",
|
|
83
|
+
" 已驻留 tmux 的 agent 就地进入;接运行中的会先确认接管)",
|
|
84
|
+
)),
|
|
85
|
+
Key(("t",), "t 终端接回", "_terminal",
|
|
86
|
+
section="后台 agent 生命周期:", help_lines=(
|
|
87
|
+
" t 终端接回(在当前终端恢复,随终端关闭而结束——兜底;",
|
|
88
|
+
" 接运行中的会先确认接管)",
|
|
83
89
|
)),
|
|
84
90
|
Key(("s",), "s 停止", "_stop", section="后台 agent 生命周期:", help_lines=(
|
|
85
91
|
" s 停止(仅运行中,需确认)",
|
|
@@ -187,14 +193,26 @@ class AgentsView(ListTabView):
|
|
|
187
193
|
self.app.trigger_async_refresh()
|
|
188
194
|
|
|
189
195
|
def _takeover(self, job: AgentJob) -> None:
|
|
196
|
+
"""Enter — tmux 接回: a tmux-resident worker is entered in place;
|
|
197
|
+
otherwise resume it inside its per-project tmux window (ADR-0001)."""
|
|
190
198
|
s = agent_ops.resume_takeover(job)
|
|
191
199
|
if s.current:
|
|
192
200
|
self.app.notify("不能接回当前会话")
|
|
193
201
|
return
|
|
194
202
|
# B1: takeover of a RUNNING worker kills its host pid (should_kill) — same
|
|
195
203
|
# as Sessions Enter-live. A dead worker resumes directly, unconfirmed.
|
|
204
|
+
confirm_tmux_takeover(
|
|
205
|
+
self.app, s, "接回后台 agent", name=job.name or job.short,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _terminal(self, job: AgentJob) -> None:
|
|
209
|
+
"""t — 终端接回 (fallback): bare-terminal resume via the existing path."""
|
|
210
|
+
s = agent_ops.resume_takeover(job)
|
|
211
|
+
if s.current:
|
|
212
|
+
self.app.notify("不能接回当前会话")
|
|
213
|
+
return
|
|
196
214
|
confirm_takeover(
|
|
197
|
-
self.app, s, "
|
|
215
|
+
self.app, s, "终端接回后台 agent",
|
|
198
216
|
lambda: self.app.exit_with(ResumeIntent(s)),
|
|
199
217
|
name=job.name or job.short,
|
|
200
218
|
)
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Shows two things:
|
|
4
4
|
1. managed projects (RCProject) with the tri-state `remoteControlAtStartup`
|
|
5
|
-
and `remoteControlSpawnMode`, plus
|
|
6
|
-
|
|
5
|
+
and `remoteControlSpawnMode`, plus Enter (start a NEW claude session in
|
|
6
|
+
the project dir inside tmux, then enter it — the tmux-first launcher,
|
|
7
|
+
ADR-0001), `o` (start the project RC server, the demoted secondary), and
|
|
8
|
+
the stop/autostart keys;
|
|
7
9
|
2. project RC servers (RCServer) discovered via tmux ∪ /proc, badged
|
|
8
10
|
managed/external — external servers are READ-ONLY (no takeover/restart key).
|
|
9
11
|
|
|
@@ -137,13 +139,14 @@ class RCView(ListTabView):
|
|
|
137
139
|
# help, and dispatch are generated from this table. `r 刷新` stays in the
|
|
138
140
|
# App-level FOOTER_PREFIX, so its entry is hint-less.
|
|
139
141
|
KEY_TABLE = (
|
|
140
|
-
Key(("
|
|
142
|
+
Key(("enter",), "Enter 新建会话", "_key_tmux_new",
|
|
141
143
|
section="项目操作(仅对「项目」行生效):", help_lines=(
|
|
142
|
-
"
|
|
144
|
+
" Enter 在项目目录新建 tmux claude 会话并直接进入(离开 csctl;",
|
|
145
|
+
" tmux-first 主入口,会话默认获得断线保护)",
|
|
143
146
|
)),
|
|
144
|
-
Key(("
|
|
147
|
+
Key(("o",), "o 启动远控", "_key_start",
|
|
145
148
|
section="项目操作(仅对「项目」行生效):", help_lines=(
|
|
146
|
-
"
|
|
149
|
+
" o 启动选中项目的远程控制服务(手机/网页控制面,次要入口)",
|
|
147
150
|
)),
|
|
148
151
|
Key(("s",), "s 停止", "_key_stop",
|
|
149
152
|
section="项目操作(仅对「项目」行生效):", help_lines=(
|