cc-session-control 0.6.4__tar.gz → 0.6.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cc_session_control-0.6.4/src/cc_session_control.egg-info → cc_session_control-0.6.5}/PKG-INFO +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/agent_ops.py +4 -12
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/session_ops.py +6 -6
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/cli.py +15 -5
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/cleanup.py +59 -71
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/liveness.py +24 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/rc.py +15 -182
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/snapshot.py +7 -30
- cc_session_control-0.6.5/src/cc_session_control/data/tmux.py +197 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/sessions.py +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.6.5/src/cc_session_control.egg-info}/PKG-INFO +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_agent_ops.py +1 -1
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_cleanup.py +2 -2
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_cli.py +4 -3
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_data.py +29 -66
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_snapshot.py +5 -5
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/LICENSE +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/README.md +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/pyproject.toml +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/setup.cfg +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/app.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/environments.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/registry.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/sessions.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/models.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/skill/SKILL.md +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_base.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_confirm.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_keytable.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_session_row.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/agents.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/views/rc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_agents.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_app.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_environments.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_liveness.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_rc.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_registry.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_sessions.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_skill_ops.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_smoke.py +0 -0
- {cc_session_control-0.6.4 → cc_session_control-0.6.5}/tests/test_views.py +0 -0
{cc_session_control-0.6.4 → cc_session_control-0.6.5}/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) --------------------------------------------------------
|
{cc_session_control-0.6.4 → cc_session_control-0.6.5}/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
|
|
|
@@ -151,7 +151,7 @@ def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
|
|
|
151
151
|
_, _, should_kill = _resume_plan(s, fork)
|
|
152
152
|
if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
|
|
153
153
|
return None
|
|
154
|
-
return
|
|
154
|
+
return tmux.run_in_tmux(tmux.session_name_for(s.cwd), s.sid[:8], cmd)
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
|
|
@@ -173,7 +173,7 @@ def attach_target(s: Session) -> str | None:
|
|
|
173
173
|
a bare terminal (those need `do_tmux_resume`)."""
|
|
174
174
|
if not s.alive or not s.pid:
|
|
175
175
|
return None
|
|
176
|
-
return
|
|
176
|
+
return tmux.find_session_window([s.pid])
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
def tmux_foreground_cmd(s: Session) -> str:
|
|
@@ -205,7 +205,7 @@ def do_tmux_new(directory: str) -> str | None:
|
|
|
205
205
|
either: the user lands inside the window, so claude's own trust dialog
|
|
206
206
|
shows interactively."""
|
|
207
207
|
cmd = f"cd {shlex.quote(directory)} && claude"
|
|
208
|
-
return
|
|
208
|
+
return tmux.run_in_tmux(tmux.session_name_for(directory), "claude", cmd)
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
# --- exit intents (the payload crossing the exit-then-exec seam) ------------
|
|
@@ -282,10 +282,10 @@ def enter_window(target: str) -> bool:
|
|
|
282
282
|
return so the caller exits csctl normally — both paths end csctl, keeping
|
|
283
283
|
"接回 = 离开 csctl" uniform. select-window failure is non-fatal (the user
|
|
284
284
|
lands in the session and can pick the window by hand)."""
|
|
285
|
-
|
|
285
|
+
tmux.select_window(target)
|
|
286
286
|
session = target.split(":", 1)[0]
|
|
287
287
|
if os.environ.get("TMUX"):
|
|
288
|
-
return
|
|
288
|
+
return tmux.switch_client(target)
|
|
289
289
|
try:
|
|
290
290
|
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
291
291
|
except OSError:
|
|
@@ -136,21 +136,31 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
def _cmd_prune(args: argparse.Namespace) -> None:
|
|
139
|
+
from .data import liveness
|
|
139
140
|
from .data.cleanup import (
|
|
140
|
-
|
|
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.6.5}/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.6.5}/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],
|
|
@@ -6,12 +6,11 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
8
|
import shlex
|
|
9
|
-
import subprocess
|
|
10
9
|
import time
|
|
11
10
|
|
|
12
11
|
from ..config import cfg
|
|
13
12
|
from ..models import EnvRecord, RCProject, RCServer, split_env_id
|
|
14
|
-
from . import environments, proc
|
|
13
|
+
from . import environments, proc, tmux
|
|
15
14
|
|
|
16
15
|
# Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
|
|
17
16
|
_ENV_ID_RE = re.compile(r"env_[A-Za-z0-9]+")
|
|
@@ -102,199 +101,33 @@ def is_trusted(proj: str) -> bool:
|
|
|
102
101
|
return False
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
# --- tmux
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
104
|
+
# --- RC-scoped thin delegates over data/tmux.py ---------------------------
|
|
105
|
+
# Bound to `cfg.rc_session`. The generic tmux adapter lives in `data/tmux.py`
|
|
106
|
+
# (the single seam — only its `_tmux_run` touches `subprocess`); these four
|
|
107
|
+
# stay here (rather than inlined at call sites) because `scan`/`scan_servers`
|
|
108
|
+
# and their tests poke these exact RC-scoped names.
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
try:
|
|
114
|
-
return subprocess.run(
|
|
115
|
-
["tmux", *args],
|
|
116
|
-
capture_output=True, text=True, timeout=5,
|
|
117
|
-
)
|
|
118
|
-
except Exception:
|
|
119
|
-
return None
|
|
111
|
+
def _tmux_windows() -> list[str]:
|
|
112
|
+
return tmux.list_windows(cfg.rc_session)
|
|
120
113
|
|
|
121
114
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
if cp is None:
|
|
125
|
-
return []
|
|
126
|
-
return [line.strip() for line in cp.stdout.splitlines() if line.strip()]
|
|
115
|
+
def _tmux_window_pids() -> dict[str, int]:
|
|
116
|
+
return tmux.window_pids(cfg.rc_session)
|
|
127
117
|
|
|
128
118
|
|
|
129
119
|
def _tmux_pane_alive(target: str) -> bool:
|
|
130
|
-
|
|
131
|
-
if cp is None:
|
|
132
|
-
return False
|
|
133
|
-
return cp.stdout.strip().split("\n")[0] == "0"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _tmux_window_pids() -> dict[str, int]:
|
|
137
|
-
"""{window_name: pane_pid} for the rc session; {} on failure.
|
|
138
|
-
|
|
139
|
-
The pane pid IS the RC server pid because `start_one` runs `exec claude …`
|
|
140
|
-
(the shell is replaced). Used to classify /proc-discovered servers as
|
|
141
|
-
managed (pid in this set) vs external.
|
|
142
|
-
"""
|
|
143
|
-
cp = _tmux_run(
|
|
144
|
-
["list-windows", "-t", cfg.rc_session, "-F", "#W\t#{pane_pid}"]
|
|
145
|
-
)
|
|
146
|
-
if cp is None:
|
|
147
|
-
return {}
|
|
148
|
-
out: dict[str, int] = {}
|
|
149
|
-
for line in cp.stdout.splitlines():
|
|
150
|
-
name, _, pid_s = line.partition("\t")
|
|
151
|
-
name = name.strip()
|
|
152
|
-
try:
|
|
153
|
-
pid = int(pid_s.strip())
|
|
154
|
-
except ValueError:
|
|
155
|
-
continue
|
|
156
|
-
if name:
|
|
157
|
-
out[name] = pid
|
|
158
|
-
return out
|
|
120
|
+
return tmux.pane_alive(target)
|
|
159
121
|
|
|
160
122
|
|
|
161
123
|
def _tmux_capture_pane(target: str) -> str:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
Captures from the start of history (`-S -`) so an `env_*` id printed at
|
|
165
|
-
server startup is still grep-able after it scrolls off the visible region.
|
|
166
|
-
"""
|
|
167
|
-
cp = _tmux_run(["capture-pane", "-p", "-S", "-", "-t", target])
|
|
168
|
-
if cp is None:
|
|
169
|
-
return ""
|
|
170
|
-
return cp.stdout
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _tmux_has_session(session: str) -> bool:
|
|
174
|
-
cp = _tmux_run(["has-session", "-t", session])
|
|
175
|
-
return cp is not None and cp.returncode == 0
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# -P -F makes tmux print the exact target of the window it just created, so
|
|
179
|
-
# callers enter THAT window even when names collide (no select-by-name guess).
|
|
180
|
-
_TARGET_FMT = "#{session_name}:#{window_index}"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _spawned_target(cp: subprocess.CompletedProcess | None) -> str | None:
|
|
184
|
-
if cp is None or cp.returncode != 0:
|
|
185
|
-
return None
|
|
186
|
-
return cp.stdout.strip() or None
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _tmux_new_window(session: str, name: str, cmd: str) -> str | None:
|
|
190
|
-
"""Create a window; return its exact "session:index" target, or None."""
|
|
191
|
-
cp = _tmux_run(["new-window", "-P", "-F", _TARGET_FMT, "-t", session, "-n", name, cmd])
|
|
192
|
-
return _spawned_target(cp)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _tmux_new_session(session: str, name: str, cmd: str) -> str | None:
|
|
196
|
-
"""Create a detached session; return its window's target, or None."""
|
|
197
|
-
cp = _tmux_run(["new-session", "-d", "-P", "-F", _TARGET_FMT, "-s", session, "-n", name, cmd])
|
|
198
|
-
return _spawned_target(cp)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def _tmux_kill_window(target: str) -> bool:
|
|
202
|
-
cp = _tmux_run(["kill-window", "-t", target])
|
|
203
|
-
return cp is not None and cp.returncode == 0
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _tmux_kill_session(session: str) -> bool:
|
|
207
|
-
cp = _tmux_run(["kill-session", "-t", session])
|
|
208
|
-
return cp is not None and cp.returncode == 0
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _tmux_windows() -> list[str]:
|
|
212
|
-
return _tmux_list_windows()
|
|
124
|
+
return tmux.capture_pane(target)
|
|
213
125
|
|
|
214
126
|
|
|
215
127
|
def _is_alive(proj: str) -> bool:
|
|
216
128
|
return _tmux_pane_alive(f"{cfg.rc_session}:{proj}")
|
|
217
129
|
|
|
218
130
|
|
|
219
|
-
def session_name_for(cwd: str) -> str:
|
|
220
|
-
"""tmux session name for a project directory: its basename, with the tmux
|
|
221
|
-
target separators `.`/`:` (illegal in session names) replaced by `-`.
|
|
222
|
-
|
|
223
|
-
One session per project — the grouping rule shared by every claude spawn
|
|
224
|
-
(t 新建 / t 接回 / R 转入后台 / agents respawn). Empty cwd → "claude"."""
|
|
225
|
-
base = cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else ""
|
|
226
|
-
name = base.replace(".", "-").replace(":", "-").strip()
|
|
227
|
-
return name or "claude"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def run_in_tmux(session: str, window: str, cmd: str) -> str | None:
|
|
231
|
-
"""Run `cmd` in a tmux `window` under `session`, creating the session if
|
|
232
|
-
it doesn't exist yet. Returns the exact "session:window_index" target of
|
|
233
|
-
the new window (None on failure) so callers can enter it unambiguously.
|
|
234
|
-
Public seam for relaunching a session outside the managed RC server
|
|
235
|
-
machinery."""
|
|
236
|
-
if _tmux_has_session(session):
|
|
237
|
-
return _tmux_new_window(session, window, cmd)
|
|
238
|
-
return _tmux_new_session(session, window, cmd)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _tmux_list_all_panes() -> list[tuple[str, int]]:
|
|
242
|
-
"""[(\"session:window_index\", pane_pid)] across ALL tmux sessions; [] on failure."""
|
|
243
|
-
cp = _tmux_run(
|
|
244
|
-
["list-panes", "-a", "-F", "#{session_name}:#{window_index}\t#{pane_pid}"]
|
|
245
|
-
)
|
|
246
|
-
if cp is None:
|
|
247
|
-
return []
|
|
248
|
-
out: list[tuple[str, int]] = []
|
|
249
|
-
for line in cp.stdout.splitlines():
|
|
250
|
-
target, _, pid_s = line.partition("\t")
|
|
251
|
-
try:
|
|
252
|
-
out.append((target.strip(), int(pid_s.strip())))
|
|
253
|
-
except ValueError:
|
|
254
|
-
continue
|
|
255
|
-
return out
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def window_containing(
|
|
259
|
-
panes: list[tuple[str, int]], ancestors: set[int]
|
|
260
|
-
) -> str | None:
|
|
261
|
-
"""PURE: the pane target whose pane_pid appears in `ancestors`, or None.
|
|
262
|
-
|
|
263
|
-
A session process hosted by a tmux pane has that pane's pid in its
|
|
264
|
-
ancestor chain (the pane pid is the window's root process)."""
|
|
265
|
-
for target, pane_pid in panes:
|
|
266
|
-
if pane_pid in ancestors:
|
|
267
|
-
return target
|
|
268
|
-
return None
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def find_session_window(pids: list[int]) -> str | None:
|
|
272
|
-
"""The tmux window (\"session:index\") hosting any of `pids`, or None.
|
|
273
|
-
|
|
274
|
-
Walks each pid's `/proc` ancestor chain and matches it against every tmux
|
|
275
|
-
pane's root pid — finds windows in ANY tmux session (cc, rc, user-made)."""
|
|
276
|
-
panes = _tmux_list_all_panes()
|
|
277
|
-
if not panes:
|
|
278
|
-
return None
|
|
279
|
-
for pid in pids:
|
|
280
|
-
target = window_containing(panes, proc.ancestors_of(pid))
|
|
281
|
-
if target:
|
|
282
|
-
return target
|
|
283
|
-
return None
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def select_window(target: str) -> bool:
|
|
287
|
-
"""tmux select-window -t <target>; False on failure (non-fatal for attach)."""
|
|
288
|
-
cp = _tmux_run(["select-window", "-t", target])
|
|
289
|
-
return cp is not None and cp.returncode == 0
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def switch_client(target: str) -> bool:
|
|
293
|
-
"""tmux switch-client -t <target> — the inside-tmux attach equivalent."""
|
|
294
|
-
cp = _tmux_run(["switch-client", "-t", target])
|
|
295
|
-
return cp is not None and cp.returncode == 0
|
|
296
|
-
|
|
297
|
-
|
|
298
131
|
def _read_rc_at_startup(directory: str) -> bool | None:
|
|
299
132
|
for name in ("settings.local.json", "settings.json"):
|
|
300
133
|
path = os.path.join(directory, ".claude", name)
|
|
@@ -456,15 +289,15 @@ def start_one(proj: str) -> bool:
|
|
|
456
289
|
f"--name {shlex.quote(remote_name)} --spawn same-dir"
|
|
457
290
|
)
|
|
458
291
|
|
|
459
|
-
return run_in_tmux(cfg.rc_session, proj, cmd) is not None
|
|
292
|
+
return tmux.run_in_tmux(cfg.rc_session, proj, cmd) is not None
|
|
460
293
|
|
|
461
294
|
|
|
462
295
|
def stop_one(proj: str) -> bool:
|
|
463
|
-
return
|
|
296
|
+
return tmux.kill_window(f"{cfg.rc_session}:{proj}")
|
|
464
297
|
|
|
465
298
|
|
|
466
299
|
def stop_all() -> bool:
|
|
467
|
-
return
|
|
300
|
+
return tmux.kill_session(cfg.rc_session)
|
|
468
301
|
|
|
469
302
|
|
|
470
303
|
def start_many(projects: list[str]) -> int:
|
{cc_session_control-0.6.4 → cc_session_control-0.6.5}/src/cc_session_control/data/snapshot.py
RENAMED
|
@@ -6,10 +6,10 @@ computes that world ONCE on the worker thread; `App` then hands the same
|
|
|
6
6
|
immutable snapshot to each view's `fetch_pending(snapshot)` so they only project
|
|
7
7
|
it (no per-view IO). Views stay back-compatible: `fetch_pending(None)` self-fetches.
|
|
8
8
|
|
|
9
|
-
This is the TOP of the data layer — it composes `sessions` / `rc` / `
|
|
10
|
-
`environments
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
This is the TOP of the data layer — it composes `sessions` / `rc` / `liveness` /
|
|
10
|
+
`environments`. Nothing in `data/` imports it (only `app`/`views` do), so there
|
|
11
|
+
is no cycle. Errors are swallowed by the callees; `App` additionally guards
|
|
12
|
+
`build_world_snapshot` so a failed build degrades to per-view self-fetch.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
18
|
|
|
19
19
|
from ..models import AgentJob, EnvRecord, RCProject, RCServer, Session, SessionProc
|
|
20
|
-
from . import environments, liveness,
|
|
20
|
+
from . import environments, liveness, rc, sessions
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
@dataclass
|
|
@@ -35,7 +35,7 @@ class WorldSnapshot:
|
|
|
35
35
|
`session_procs` (with `/proc` liveness already injected), `agents_map`
|
|
36
36
|
(`claude agents --json`) and `cur` (the ancestor-pid set) are the raw liveness
|
|
37
37
|
inputs `build_world_snapshot` already computes for the scan; they are exposed
|
|
38
|
-
here so the Sessions cleanup submenu can feed `
|
|
38
|
+
here so the Sessions cleanup submenu can feed `cleanup.build_plan` /
|
|
39
39
|
`select_zombie_pids` WITHOUT a second scan (R11/D8).
|
|
40
40
|
"""
|
|
41
41
|
sessions: list[Session] = field(default_factory=list)
|
|
@@ -49,29 +49,6 @@ class WorldSnapshot:
|
|
|
49
49
|
cur: set[int] = field(default_factory=set)
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def liveness_inputs() -> tuple[
|
|
53
|
-
list[SessionProc], set[int], list[AgentJob], dict[str, int | None]
|
|
54
|
-
]:
|
|
55
|
-
"""The shared liveness inputs — `(session_procs, cur, agent_jobs,
|
|
56
|
-
agents_map)` — fetched ONCE, jobs already host-enriched.
|
|
57
|
-
|
|
58
|
-
Both `build_world_snapshot` and the Sessions view's `fetch_pending(None)`
|
|
59
|
-
self-fetch consume this, so the degraded path is the same assembly instead
|
|
60
|
-
of a hand-kept mirror that can drift. Each read swallows its own errors →
|
|
61
|
-
safe empties.
|
|
62
|
-
"""
|
|
63
|
-
session_procs = liveness.live_session_procs()
|
|
64
|
-
try:
|
|
65
|
-
agent_jobs = liveness.enrich_jobs(registry.read_agent_jobs(), session_procs)
|
|
66
|
-
except Exception:
|
|
67
|
-
agent_jobs = []
|
|
68
|
-
try:
|
|
69
|
-
agents_map = liveness.alive_map()
|
|
70
|
-
except Exception:
|
|
71
|
-
agents_map = {}
|
|
72
|
-
return session_procs, proc.ancestor_pids(), agent_jobs, agents_map
|
|
73
|
-
|
|
74
|
-
|
|
75
52
|
def build_world_snapshot() -> WorldSnapshot:
|
|
76
53
|
"""Compute the shared per-cycle world once (worker thread, R11/D8).
|
|
77
54
|
|
|
@@ -80,7 +57,7 @@ def build_world_snapshot() -> WorldSnapshot:
|
|
|
80
57
|
registry reads are ~5s-TTL cached so the few repeat reads inside `scan()`
|
|
81
58
|
hit the cache. Each callee swallows its own errors and returns safe empties.
|
|
82
59
|
"""
|
|
83
|
-
session_procs, cur, agent_jobs, agents_map = liveness_inputs()
|
|
60
|
+
session_procs, cur, agent_jobs, agents_map = liveness.liveness_inputs()
|
|
84
61
|
all_sessions = sessions.scan()
|
|
85
62
|
rc_projects = rc.scan()
|
|
86
63
|
rc_servers = rc.scan_servers()
|