cc-session-control 0.6.4__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.4/src/cc_session_control.egg-info → cc_session_control-0.7.0}/PKG-INFO +3 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/README.md +2 -2
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py +9 -13
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py +42 -58
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/app.py +6 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/cli.py +15 -5
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/cleanup.py +59 -71
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/liveness.py +24 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/rc.py +15 -182
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/sessions.py +39 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/snapshot.py +7 -30
- cc_session_control-0.7.0/src/cc_session_control/data/tmux.py +217 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/models.py +5 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/skill/SKILL.md +3 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_confirm.py +36 -1
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_session_row.py +10 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/agents.py +22 -4
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/rc.py +9 -6
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/sessions.py +35 -43
- {cc_session_control-0.6.4 → cc_session_control-0.7.0/src/cc_session_control.egg-info}/PKG-INFO +3 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_agent_ops.py +7 -1
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_agents.py +55 -24
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_app.py +26 -9
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_cleanup.py +2 -2
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_cli.py +4 -3
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_data.py +97 -150
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_sessions.py +45 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_skill_ops.py +6 -4
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_snapshot.py +5 -5
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_views.py +122 -33
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/LICENSE +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/pyproject.toml +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/setup.cfg +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/environments.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/registry.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_base.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_keytable.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_environments.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_liveness.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_rc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_registry.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_smoke.py +0 -0
{cc_session_control-0.6.4/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.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py
RENAMED
|
@@ -25,7 +25,7 @@ import os
|
|
|
25
25
|
import shlex
|
|
26
26
|
|
|
27
27
|
from ..config import cfg
|
|
28
|
-
from ..data import cleanup, liveness, proc,
|
|
28
|
+
from ..data import cleanup, liveness, proc, registry, tmux
|
|
29
29
|
from ..models import AgentJob, Session
|
|
30
30
|
from . import session_ops
|
|
31
31
|
|
|
@@ -71,12 +71,12 @@ def respawn(job: AgentJob) -> str:
|
|
|
71
71
|
"""Relaunch a background agent in tmux; returns the exact command string.
|
|
72
72
|
|
|
73
73
|
Runs `respawn_cmd(job)` in the job's per-project tmux session
|
|
74
|
-
(`
|
|
74
|
+
(`tmux.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
|
|
75
75
|
os.exec/replace the csctl process. The returned string also feeds the
|
|
76
76
|
clipboard `y`-style key.
|
|
77
77
|
"""
|
|
78
78
|
cmd = respawn_cmd(job)
|
|
79
|
-
|
|
79
|
+
tmux.run_in_tmux(tmux.session_name_for(job.cwd), _job_window(job), cmd)
|
|
80
80
|
return cmd
|
|
81
81
|
|
|
82
82
|
|
|
@@ -94,15 +94,7 @@ def remove_job(job: AgentJob) -> bool:
|
|
|
94
94
|
_, alive = job_host(job)
|
|
95
95
|
if alive:
|
|
96
96
|
return False
|
|
97
|
-
|
|
98
|
-
removed = cleanup._remove_path(job_dir)
|
|
99
|
-
# Reuse cleanup's artifact-path helper / remover: it returns the sid-keyed
|
|
100
|
-
# dirs (session-env/file-history/tasks/uploads) plus jobs/<sid[:8]> (which
|
|
101
|
-
# usually equals job_dir — a second remove is a harmless no-op), so the job's
|
|
102
|
-
# session leaves no orphan artifacts behind.
|
|
103
|
-
for path in cleanup._session_artifact_paths(job.sid):
|
|
104
|
-
cleanup._remove_path(path)
|
|
105
|
-
return removed
|
|
97
|
+
return cleanup.remove_agent_artifacts(job.short, job.sid)
|
|
106
98
|
|
|
107
99
|
|
|
108
100
|
# --- watch (read-only) --------------------------------------------------------
|
|
@@ -128,7 +120,10 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
128
120
|
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
129
121
|
`pid`/`alive` come from the host join so a live worker is killed first
|
|
130
122
|
(resume = takeover); `current` is computed so the launching session stays
|
|
131
|
-
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.
|
|
132
127
|
"""
|
|
133
128
|
pid, alive = job_host(job)
|
|
134
129
|
current = bool(pid) and pid in proc.ancestor_pids()
|
|
@@ -144,6 +139,7 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
144
139
|
proc_start=_host_start(pid),
|
|
145
140
|
source="bg",
|
|
146
141
|
agent_short=job.short,
|
|
142
|
+
tmux_target=tmux.find_session_window([pid]) if alive and pid else None,
|
|
147
143
|
)
|
|
148
144
|
|
|
149
145
|
|
{cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py
RENAMED
|
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|
|
10
10
|
from typing import Literal
|
|
11
11
|
|
|
12
12
|
from .. import clipboard
|
|
13
|
-
from ..data import proc,
|
|
13
|
+
from ..data import proc, tmux
|
|
14
14
|
from ..data.liveness import invalidate_cache
|
|
15
15
|
from ..models import Session
|
|
16
16
|
|
|
@@ -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,86 +126,67 @@ 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
|
|
205
186
|
either: the user lands inside the window, so claude's own trust dialog
|
|
206
187
|
shows interactively."""
|
|
207
188
|
cmd = f"cd {shlex.quote(directory)} && claude"
|
|
208
|
-
return
|
|
189
|
+
return tmux.run_in_tmux(tmux.session_name_for(directory), "claude", cmd)
|
|
209
190
|
|
|
210
191
|
|
|
211
192
|
# --- exit intents (the payload crossing the exit-then-exec seam) ------------
|
|
@@ -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
|
|
|
@@ -282,10 +266,10 @@ def enter_window(target: str) -> bool:
|
|
|
282
266
|
return so the caller exits csctl normally — both paths end csctl, keeping
|
|
283
267
|
"接回 = 离开 csctl" uniform. select-window failure is non-fatal (the user
|
|
284
268
|
lands in the session and can pick the window by hand)."""
|
|
285
|
-
|
|
269
|
+
tmux.select_window(target)
|
|
286
270
|
session = target.split(":", 1)[0]
|
|
287
271
|
if os.environ.get("TMUX"):
|
|
288
|
-
return
|
|
272
|
+
return tmux.switch_client(target)
|
|
289
273
|
try:
|
|
290
274
|
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
291
275
|
except OSError:
|
|
@@ -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)
|
|
@@ -136,21 +136,31 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
def _cmd_prune(args: argparse.Namespace) -> None:
|
|
139
|
+
from .data import liveness
|
|
139
140
|
from .data.cleanup import (
|
|
140
|
-
|
|
141
|
+
build_plan,
|
|
141
142
|
execute_orphan_removals,
|
|
142
143
|
execute_session_removals,
|
|
143
|
-
list_orphan_dirs,
|
|
144
144
|
prune_sessions,
|
|
145
145
|
)
|
|
146
146
|
from .data.sessions import scan
|
|
147
147
|
|
|
148
148
|
sessions = scan()
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
# One shared fetch feeds the frozen plan — the SAME assembly build_plan's
|
|
150
|
+
# other callers (the Sessions view, build_world_snapshot) use, so the CLI
|
|
151
|
+
# header and any future TUI parity stay derived from one source (删除 ⊆ 预览
|
|
152
|
+
# still holds: execute_* revalidate against fresh data at apply time).
|
|
153
|
+
procs, cur, jobs, agents = liveness.liveness_inputs()
|
|
154
|
+
plan = build_plan(sessions, procs, cur, jobs, agents)
|
|
155
|
+
counts = plan.counts()
|
|
156
|
+
print(
|
|
157
|
+
f"Total: {len(sessions)} Prunable empty: {counts['empty']} "
|
|
158
|
+
f"short(<=2): {counts['short']} Orphan dirs: {counts['orphan_dirs']} "
|
|
159
|
+
f"Zombie files: {counts['zombie_procs']} Aged: {counts['aged_entries']}"
|
|
160
|
+
)
|
|
151
161
|
|
|
152
162
|
if args.sweep_orphans:
|
|
153
|
-
orphans =
|
|
163
|
+
orphans = plan.orphan_entries
|
|
154
164
|
print(f"Would sweep {len(orphans)} orphan artifact dir(s)")
|
|
155
165
|
if not args.apply:
|
|
156
166
|
print("Dry run. Add --apply to execute.")
|
{cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/cleanup.py
RENAMED
|
@@ -80,9 +80,10 @@ def _session_artifact_paths(sid: str) -> list[str]:
|
|
|
80
80
|
"""All on-disk artifact paths owned by one session id (cfg-derived).
|
|
81
81
|
|
|
82
82
|
Covers the sid-keyed dirs plus the 8-char-prefixed `jobs/<short>` dir for
|
|
83
|
-
this session. Used by `
|
|
84
|
-
the job). `remove_session`
|
|
85
|
-
|
|
83
|
+
this session. Used by `remove_agent_artifacts` (whose caller,
|
|
84
|
+
`agent_ops.remove_job`, has already alive-gated the job). `remove_session`
|
|
85
|
+
does NOT use this — it guards the `jobs/<short>` path separately so a LIVE
|
|
86
|
+
agent worker's jobs dir is never deleted (M3).
|
|
86
87
|
"""
|
|
87
88
|
return _sid_keyed_paths(sid) + [_jobs_path(sid)]
|
|
88
89
|
|
|
@@ -101,6 +102,26 @@ def _remove_path(path: str) -> bool:
|
|
|
101
102
|
return False
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
def remove_agent_artifacts(short: str, sid: str) -> bool:
|
|
106
|
+
"""Delete a settled background agent's `jobs/<short>` dir + sid artifacts.
|
|
107
|
+
|
|
108
|
+
Removes `jobs/<short>` first (the return value is True iff THAT removal
|
|
109
|
+
happened), then every path in `_session_artifact_paths(sid)` (the sid-keyed
|
|
110
|
+
dirs plus `jobs/<sid[:8]>` — a second remove of the same job dir when
|
|
111
|
+
`short == sid[:8]` is a harmless no-op).
|
|
112
|
+
|
|
113
|
+
The CALLER owns the gates: this function assumes the job has already been
|
|
114
|
+
alive-gated (a LIVE worker must never reach here) and that
|
|
115
|
+
`proc.current_determinable()` (R10) has already been checked — it only
|
|
116
|
+
deletes.
|
|
117
|
+
"""
|
|
118
|
+
job_dir = os.path.join(str(cfg.jobs_dir), short)
|
|
119
|
+
removed = _remove_path(job_dir)
|
|
120
|
+
for path in _session_artifact_paths(sid):
|
|
121
|
+
_remove_path(path)
|
|
122
|
+
return removed
|
|
123
|
+
|
|
124
|
+
|
|
104
125
|
# --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
|
|
105
126
|
|
|
106
127
|
def known_sids(
|
|
@@ -141,6 +162,33 @@ def known_sids(
|
|
|
141
162
|
return known
|
|
142
163
|
|
|
143
164
|
|
|
165
|
+
def _fill_liveness_inputs(
|
|
166
|
+
session_procs: list[SessionProc] | None,
|
|
167
|
+
agent_jobs: list[AgentJob] | None,
|
|
168
|
+
agents_map: dict[str, int | None] | None,
|
|
169
|
+
cur: set[int] | None,
|
|
170
|
+
) -> tuple[list[SessionProc], list[AgentJob], dict[str, int | None], set[int]]:
|
|
171
|
+
"""Fill any None input from `liveness.liveness_inputs()` (the ONE assembly).
|
|
172
|
+
|
|
173
|
+
Snapshot/view callers inject the shared world data (R11) and pay no IO
|
|
174
|
+
here; CLI / no-snapshot callers pass None and get the same assembly
|
|
175
|
+
`build_world_snapshot` uses (which swallows each source's errors → safe
|
|
176
|
+
empties). A caller that doesn't consume a source passes `[]`/`{}` — not
|
|
177
|
+
None — so an unused gap never triggers the fetch on its own.
|
|
178
|
+
"""
|
|
179
|
+
if session_procs is None or agent_jobs is None or agents_map is None or cur is None:
|
|
180
|
+
d_procs, d_cur, d_jobs, d_agents = liveness.liveness_inputs()
|
|
181
|
+
if session_procs is None:
|
|
182
|
+
session_procs = d_procs
|
|
183
|
+
if agent_jobs is None:
|
|
184
|
+
agent_jobs = d_jobs
|
|
185
|
+
if agents_map is None:
|
|
186
|
+
agents_map = d_agents
|
|
187
|
+
if cur is None:
|
|
188
|
+
cur = d_cur
|
|
189
|
+
return session_procs, agent_jobs, agents_map, cur
|
|
190
|
+
|
|
191
|
+
|
|
144
192
|
def _gather_known(
|
|
145
193
|
sessions: list[Session],
|
|
146
194
|
session_procs: list[SessionProc] | None = None,
|
|
@@ -148,26 +196,10 @@ def _gather_known(
|
|
|
148
196
|
agents_map: dict[str, int | None] | None = None,
|
|
149
197
|
cur: set[int] | None = None,
|
|
150
198
|
) -> set[str]:
|
|
151
|
-
"""Resolve the protected-sid set, self-fetching any omitted source
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
self-read swallows its own errors → safe empties.
|
|
156
|
-
"""
|
|
157
|
-
if session_procs is None:
|
|
158
|
-
session_procs = liveness.live_session_procs()
|
|
159
|
-
if agent_jobs is None:
|
|
160
|
-
try:
|
|
161
|
-
agent_jobs = registry.read_agent_jobs()
|
|
162
|
-
except Exception:
|
|
163
|
-
agent_jobs = []
|
|
164
|
-
if agents_map is None:
|
|
165
|
-
try:
|
|
166
|
-
agents_map = liveness.alive_map()
|
|
167
|
-
except Exception:
|
|
168
|
-
agents_map = {}
|
|
169
|
-
if cur is None:
|
|
170
|
-
cur = proc.ancestor_pids()
|
|
199
|
+
"""Resolve the protected-sid set, self-fetching any omitted source
|
|
200
|
+
(`_fill_liveness_inputs`)."""
|
|
201
|
+
session_procs, agent_jobs, agents_map, cur = _fill_liveness_inputs(
|
|
202
|
+
session_procs, agent_jobs, agents_map, cur)
|
|
171
203
|
return known_sids(sessions, session_procs, agent_jobs, agents_map, cur)
|
|
172
204
|
|
|
173
205
|
|
|
@@ -399,18 +431,10 @@ def execute_session_removals(
|
|
|
399
431
|
"""
|
|
400
432
|
if not proc.current_determinable():
|
|
401
433
|
return 0
|
|
402
|
-
#
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
session_procs = liveness.live_session_procs()
|
|
407
|
-
if agents_map is None:
|
|
408
|
-
try:
|
|
409
|
-
agents_map = liveness.alive_map()
|
|
410
|
-
except Exception:
|
|
411
|
-
agents_map = {}
|
|
412
|
-
if cur is None:
|
|
413
|
-
cur = proc.ancestor_pids()
|
|
434
|
+
# agent_jobs is unused here: pass [] (not None) so an omitted-but-unused
|
|
435
|
+
# source never triggers the fetch on its own (see _fill_liveness_inputs).
|
|
436
|
+
session_procs, _, agents_map, cur = _fill_liveness_inputs(
|
|
437
|
+
session_procs, [], agents_map, cur)
|
|
414
438
|
fresh_alive = {sp.sid for sp in session_procs if sp.proc_alive}
|
|
415
439
|
fresh_alive |= {sid for sid, pid in agents_map.items() if pid}
|
|
416
440
|
count = 0
|
|
@@ -470,39 +494,3 @@ def build_plan(
|
|
|
470
494
|
zombie_pids=select_zombie_pids(session_procs, cur),
|
|
471
495
|
aged_entries=list_aged_entries(now),
|
|
472
496
|
)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
# --- Classified counts -----------------------------------------------------
|
|
476
|
-
|
|
477
|
-
def cleanup_stats(sessions: list[Session]) -> dict[str, int]:
|
|
478
|
-
"""Summary counts for the Sessions cleanup submenu (view-facing contract).
|
|
479
|
-
|
|
480
|
-
Keeps the established 4-key shape (`total/empty/short/orphans`) that the
|
|
481
|
-
view reads; `orphans` is the sid-keyed orphan count (Strategy A).
|
|
482
|
-
"""
|
|
483
|
-
return {
|
|
484
|
-
"total": len(sessions),
|
|
485
|
-
"empty": sum(1 for s in sessions if s.prompts == 0),
|
|
486
|
-
"short": sum(1 for s in sessions if 0 < s.prompts <= 2),
|
|
487
|
-
"orphans": len(list_orphan_dirs(sessions)),
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def cleanup_classified(
|
|
492
|
-
sessions: list[Session],
|
|
493
|
-
session_procs: list[SessionProc],
|
|
494
|
-
cur: set[int],
|
|
495
|
-
agent_jobs: list[AgentJob] | None = None,
|
|
496
|
-
agents_map: dict[str, int | None] | None = None,
|
|
497
|
-
now: float | None = None,
|
|
498
|
-
) -> dict[str, int]:
|
|
499
|
-
"""Per-category cleanup counts (D6) — a thin view over `build_plan`.
|
|
500
|
-
|
|
501
|
-
Deriving the counts FROM the plan keeps them equal to what a preview would
|
|
502
|
-
show: `empty`/`short` count *prunable* sessions (not alive/current/recent),
|
|
503
|
-
matching the preview list instead of the raw prompt tally.
|
|
504
|
-
"""
|
|
505
|
-
return build_plan(
|
|
506
|
-
sessions, session_procs, cur,
|
|
507
|
-
agent_jobs=agent_jobs, agents_map=agents_map, now=now,
|
|
508
|
-
).counts()
|
{cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/liveness.py
RENAMED
|
@@ -20,6 +20,30 @@ _cache: dict[str, int | None] | None = None
|
|
|
20
20
|
_cache_time: float = 0.0
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def liveness_inputs() -> tuple[
|
|
24
|
+
list[SessionProc], set[int], list[AgentJob], dict[str, int | None]
|
|
25
|
+
]:
|
|
26
|
+
"""The shared liveness inputs — `(session_procs, cur, agent_jobs,
|
|
27
|
+
agents_map)` — fetched ONCE, jobs already host-enriched.
|
|
28
|
+
|
|
29
|
+
`build_world_snapshot`, the Sessions view's `fetch_pending(None)`
|
|
30
|
+
self-fetch, and `cleanup`'s protection-set assembly all consume this, so
|
|
31
|
+
the degraded/self-fetch paths are the same assembly instead of a
|
|
32
|
+
hand-kept mirror that can drift. Each read swallows its own errors → safe
|
|
33
|
+
empties.
|
|
34
|
+
"""
|
|
35
|
+
session_procs = live_session_procs()
|
|
36
|
+
try:
|
|
37
|
+
agent_jobs = enrich_jobs(registry.read_agent_jobs(), session_procs)
|
|
38
|
+
except Exception:
|
|
39
|
+
agent_jobs = []
|
|
40
|
+
try:
|
|
41
|
+
agents_map = alive_map()
|
|
42
|
+
except Exception:
|
|
43
|
+
agents_map = {}
|
|
44
|
+
return session_procs, proc.ancestor_pids(), agent_jobs, agents_map
|
|
45
|
+
|
|
46
|
+
|
|
23
47
|
def _scrub_dead_pids(
|
|
24
48
|
mapping: dict[str, int | None],
|
|
25
49
|
exists: Callable[[int | None], bool],
|