cc-session-control 0.6.3__tar.gz → 0.6.4__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.3/src/cc_session_control.egg-info → cc_session_control-0.6.4}/PKG-INFO +1 -1
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/agent_ops.py +23 -16
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/session_ops.py +63 -58
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/app.py +16 -16
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/cli.py +27 -34
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/cleanup.py +178 -60
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/environments.py +35 -21
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/liveness.py +25 -2
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/registry.py +6 -5
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/sessions.py +3 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/snapshot.py +32 -38
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/models.py +26 -1
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_base.py +5 -4
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_confirm.py +32 -0
- cc_session_control-0.6.4/src/cc_session_control/views/_sessions_cleanup.py +175 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/agents.py +12 -29
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/rc.py +5 -7
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/sessions.py +42 -77
- {cc_session_control-0.6.3 → cc_session_control-0.6.4/src/cc_session_control.egg-info}/PKG-INFO +1 -1
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_agent_ops.py +11 -4
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_agents.py +4 -6
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_app.py +41 -10
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_cleanup.py +75 -9
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_data.py +51 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_environments.py +42 -13
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_registry.py +8 -1
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_snapshot.py +0 -2
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_views.py +50 -62
- cc_session_control-0.6.3/src/cc_session_control/views/_sessions_cleanup.py +0 -161
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/LICENSE +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/README.md +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/pyproject.toml +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/setup.cfg +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/data/rc.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/skill/SKILL.md +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_keytable.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/views/_session_row.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/SOURCES.txt +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_cli.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_liveness.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_rc.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_sessions.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_skill_ops.py +0 -0
- {cc_session_control-0.6.3 → cc_session_control-0.6.4}/tests/test_smoke.py +0 -0
{cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/agent_ops.py
RENAMED
|
@@ -23,12 +23,11 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import os
|
|
25
25
|
import shlex
|
|
26
|
-
import signal
|
|
27
|
-
import time
|
|
28
26
|
|
|
29
27
|
from ..config import cfg
|
|
30
28
|
from ..data import cleanup, liveness, proc, rc, registry
|
|
31
29
|
from ..models import AgentJob, Session
|
|
30
|
+
from . import session_ops
|
|
32
31
|
|
|
33
32
|
# --- host-pid join (shared by stop_job, remove_job, and the view) -------------
|
|
34
33
|
|
|
@@ -142,11 +141,27 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
142
141
|
pid=pid,
|
|
143
142
|
alive=alive,
|
|
144
143
|
current=current,
|
|
144
|
+
proc_start=_host_start(pid),
|
|
145
145
|
source="bg",
|
|
146
146
|
agent_short=job.short,
|
|
147
147
|
)
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def _host_start(pid: int | None) -> str:
|
|
151
|
+
"""`proc_start` of the joined host pid ("" when unknown).
|
|
152
|
+
|
|
153
|
+
`job_host` deliberately keeps its small `(pid, alive)` shape; this second
|
|
154
|
+
lookup feeds `take_over`'s kill-time `pid_alive` recheck so the pid-reuse
|
|
155
|
+
window is closed on the bg-agent path too, not just for scanned sessions.
|
|
156
|
+
"""
|
|
157
|
+
if not pid:
|
|
158
|
+
return ""
|
|
159
|
+
return next(
|
|
160
|
+
(sp.proc_start for sp in liveness.live_session_procs() if sp.pid == pid),
|
|
161
|
+
"",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
150
165
|
# --- stop (live workers only) -------------------------------------------------
|
|
151
166
|
|
|
152
167
|
def stop_job(job: AgentJob) -> bool:
|
|
@@ -154,23 +169,15 @@ def stop_job(job: AgentJob) -> bool:
|
|
|
154
169
|
|
|
155
170
|
The host pid is JOINed from `sessions/<pid>.json` (`job_host`); only a
|
|
156
171
|
confirmed-live pid is killed — a worker with no sessions file is unstoppable
|
|
157
|
-
(no-op False, orphan risk).
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
(no-op False, orphan risk). The kill itself is `session_ops.take_over` (the
|
|
173
|
+
ONE primitive: R10 gate, recheck, SIGTERM, cache invalidation); the early
|
|
174
|
+
R10 check here just skips the join IO when the answer is already no.
|
|
175
|
+
Killing does not always fully reap a `--remote-control`/bg worker (orphan
|
|
176
|
+
risk, see `HELP`).
|
|
161
177
|
"""
|
|
162
178
|
if not proc.current_determinable():
|
|
163
179
|
return False
|
|
164
180
|
pid, alive = job_host(job)
|
|
165
181
|
if not alive or not pid:
|
|
166
182
|
return False
|
|
167
|
-
|
|
168
|
-
os.kill(pid, signal.SIGTERM)
|
|
169
|
-
except ProcessLookupError:
|
|
170
|
-
liveness.invalidate_cache() # already gone — liveness changed
|
|
171
|
-
return True
|
|
172
|
-
except Exception:
|
|
173
|
-
return False
|
|
174
|
-
time.sleep(1)
|
|
175
|
-
liveness.invalidate_cache()
|
|
176
|
-
return True
|
|
183
|
+
return session_ops.take_over(pid, _host_start(pid)) in session_ops.TAKE_OVER_OK
|
{cc_session_control-0.6.3 → cc_session_control-0.6.4}/src/cc_session_control/actions/session_ops.py
RENAMED
|
@@ -7,39 +7,61 @@ import shlex
|
|
|
7
7
|
import signal
|
|
8
8
|
import time
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
10
11
|
|
|
11
12
|
from .. import clipboard
|
|
12
13
|
from ..data import proc, rc
|
|
13
14
|
from ..data.liveness import invalidate_cache
|
|
14
15
|
from ..models import Session
|
|
15
16
|
|
|
17
|
+
TakeOverResult = Literal["killed", "gone", "refused", "failed"]
|
|
18
|
+
|
|
19
|
+
#: The results a fail-fast caller counts as success (signalled, or nothing
|
|
20
|
+
#: left to signal) — single-sourced so terminate/stop can't diverge on it.
|
|
21
|
+
TAKE_OVER_OK = ("killed", "gone")
|
|
16
22
|
|
|
17
|
-
def terminate_session(s: Session) -> bool:
|
|
18
|
-
"""Send SIGTERM and own the liveness-cache invalidation.
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
don't.)
|
|
24
|
+
def take_over(pid: int, proc_start: str = "") -> TakeOverResult:
|
|
25
|
+
"""THE kill primitive behind every takeover/stop: R10 gate → kill-time
|
|
26
|
+
liveness recheck → SIGTERM → settle → invalidate the liveness cache.
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
One implementation so the gate order and the kill semantics cannot fork
|
|
29
|
+
across the resume/terminate/stop variants (they had already started to:
|
|
30
|
+
only terminate/stop skipped the settle sleep for an already-gone pid).
|
|
31
|
+
The recheck (`pid_alive` against `proc_start`; mere existence when the
|
|
32
|
+
start is unknown) closes the pid-reuse window — a confirm modal can sit
|
|
33
|
+
open for minutes, and a recycled pid must never be SIGTERMed.
|
|
34
|
+
|
|
35
|
+
Results: "killed" (signalled + settled), "gone" (already dead / recycled —
|
|
36
|
+
nothing to kill), "refused" (R10: current undeterminable), "failed"
|
|
37
|
+
(signal error, e.g. permissions). Fail-fast callers (terminate/stop) treat
|
|
38
|
+
"failed" as failure; best-effort callers (the resume family) continue.
|
|
28
39
|
"""
|
|
29
40
|
if not proc.current_determinable():
|
|
30
|
-
return
|
|
31
|
-
if not
|
|
32
|
-
|
|
41
|
+
return "refused"
|
|
42
|
+
if not proc.pid_alive(pid, proc_start):
|
|
43
|
+
invalidate_cache() # already gone — liveness may have changed
|
|
44
|
+
return "gone"
|
|
33
45
|
try:
|
|
34
|
-
os.kill(
|
|
46
|
+
os.kill(pid, signal.SIGTERM)
|
|
35
47
|
except ProcessLookupError:
|
|
36
|
-
invalidate_cache()
|
|
37
|
-
return
|
|
48
|
+
invalidate_cache()
|
|
49
|
+
return "gone"
|
|
38
50
|
except Exception:
|
|
39
|
-
return
|
|
51
|
+
return "failed"
|
|
40
52
|
time.sleep(1)
|
|
41
53
|
invalidate_cache()
|
|
42
|
-
return
|
|
54
|
+
return "killed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def terminate_session(s: Session) -> bool:
|
|
58
|
+
"""Send SIGTERM via `take_over` (which owns the R10 gate + the
|
|
59
|
+
liveness-cache invalidation — terminating is the one session op that
|
|
60
|
+
changes `claude agents` liveness; delete/cleanup only touch already-dead
|
|
61
|
+
sessions, so they don't invalidate)."""
|
|
62
|
+
if not s.pid:
|
|
63
|
+
return False
|
|
64
|
+
return take_over(s.pid, s.proc_start) in TAKE_OVER_OK
|
|
43
65
|
|
|
44
66
|
|
|
45
67
|
def _resume_plan(s: Session, fork: bool = False) -> tuple[str, list[str], bool]:
|
|
@@ -90,18 +112,15 @@ def do_resume(s: Session, fork: bool = False) -> None:
|
|
|
90
112
|
we never SIGTERM the launching session (every pid looks dead off `/proc`).
|
|
91
113
|
"""
|
|
92
114
|
cwd, args, should_kill = _resume_plan(s, fork)
|
|
93
|
-
if should_kill:
|
|
94
|
-
if
|
|
115
|
+
if should_kill and s.pid:
|
|
116
|
+
if take_over(s.pid, s.proc_start) == "refused":
|
|
95
117
|
print(
|
|
96
118
|
"Refused: '/proc' unavailable — cannot determine the current "
|
|
97
119
|
"session, so the old process can't be safely killed (R10)."
|
|
98
120
|
)
|
|
99
121
|
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
except Exception:
|
|
103
|
-
pass
|
|
104
|
-
time.sleep(1)
|
|
122
|
+
# "gone"/"failed" fall through: the kill is best-effort here, the
|
|
123
|
+
# resume itself must still happen.
|
|
105
124
|
if cwd and os.path.isdir(cwd):
|
|
106
125
|
os.chdir(cwd)
|
|
107
126
|
os.execvp("claude", args)
|
|
@@ -121,30 +140,29 @@ def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
|
|
|
121
140
|
return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
|
|
122
141
|
|
|
123
142
|
|
|
143
|
+
def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
|
|
144
|
+
"""Kill-if-takeover, then spawn `cmd` in the session's per-project tmux
|
|
145
|
+
session — the shared skeleton behind `relaunch_in_tmux` (R key, with
|
|
146
|
+
--remote-control) and `do_tmux_resume` (t key, foreground): the two
|
|
147
|
+
differed only in the command builder. Returns the exact tmux target,
|
|
148
|
+
or None on R10 refusal ("refused" from `take_over`) / tmux failure;
|
|
149
|
+
"gone"/"failed" fall through like `do_resume` (best-effort kill).
|
|
150
|
+
"""
|
|
151
|
+
_, _, should_kill = _resume_plan(s, fork)
|
|
152
|
+
if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
|
|
153
|
+
return None
|
|
154
|
+
return rc.run_in_tmux(rc.session_name_for(s.cwd), s.sid[:8], cmd)
|
|
155
|
+
|
|
156
|
+
|
|
124
157
|
def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
|
|
125
158
|
"""Relaunch a session as `claude --resume … --remote-control …` inside a
|
|
126
159
|
tmux window, so it outlives the terminal and is remotely controllable.
|
|
127
160
|
|
|
128
|
-
A live, non-current session is taken over (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
R10: when a takeover kill is required but "current" can't be determined (no
|
|
133
|
-
`/proc`), refuse (return False, do not kill or relaunch) — we can't prove `s`
|
|
134
|
-
is not the launching session.
|
|
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.
|
|
135
164
|
"""
|
|
136
|
-
|
|
137
|
-
if should_kill and s.pid:
|
|
138
|
-
if not proc.current_determinable():
|
|
139
|
-
return False
|
|
140
|
-
try:
|
|
141
|
-
os.kill(s.pid, signal.SIGTERM)
|
|
142
|
-
except Exception:
|
|
143
|
-
pass
|
|
144
|
-
time.sleep(1)
|
|
145
|
-
invalidate_cache()
|
|
146
|
-
session = rc.session_name_for(s.cwd)
|
|
147
|
-
return rc.run_in_tmux(session, s.sid[:8], tmux_resume_cmd(s, fork)) is not None
|
|
165
|
+
return _spawn_in_tmux(s, tmux_resume_cmd(s, fork), fork=fork) is not None
|
|
148
166
|
|
|
149
167
|
|
|
150
168
|
def attach_target(s: Session) -> str | None:
|
|
@@ -172,21 +190,8 @@ def tmux_foreground_cmd(s: Session) -> str:
|
|
|
172
190
|
def do_tmux_resume(s: Session) -> str | None:
|
|
173
191
|
"""Kill-if-takeover, spawn the resume window in the session's per-project
|
|
174
192
|
tmux session, and return the exact tmux target to enter; None on failure.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
+ R10 refusal); the caller (cli) then calls `enter_window` on the target."""
|
|
178
|
-
_, _, should_kill = _resume_plan(s)
|
|
179
|
-
if should_kill and s.pid:
|
|
180
|
-
if not proc.current_determinable():
|
|
181
|
-
return None
|
|
182
|
-
try:
|
|
183
|
-
os.kill(s.pid, signal.SIGTERM)
|
|
184
|
-
except Exception:
|
|
185
|
-
pass
|
|
186
|
-
time.sleep(1)
|
|
187
|
-
invalidate_cache()
|
|
188
|
-
session = rc.session_name_for(s.cwd)
|
|
189
|
-
return rc.run_in_tmux(session, s.sid[:8], tmux_foreground_cmd(s))
|
|
193
|
+
The caller (cli) then calls `enter_window` on the target."""
|
|
194
|
+
return _spawn_in_tmux(s, tmux_foreground_cmd(s))
|
|
190
195
|
|
|
191
196
|
|
|
192
197
|
def do_tmux_new(directory: str) -> str | None:
|
|
@@ -45,7 +45,7 @@ class TabView(Protocol):
|
|
|
45
45
|
def apply_data(self) -> None: ...
|
|
46
46
|
def keyhints(self) -> str: ...
|
|
47
47
|
def handle_key(self, key: str) -> None: ...
|
|
48
|
-
def
|
|
48
|
+
def captures_text(self) -> bool: ... # True while a text mode owns EVERY key (incl. tab/q)
|
|
49
49
|
|
|
50
50
|
# 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
|
|
51
51
|
# ONE semantic set — views reference these names only (no per-tab duplicates;
|
|
@@ -146,10 +146,6 @@ class App:
|
|
|
146
146
|
self.header.contents[1] = (tab_bar, self.header.options())
|
|
147
147
|
|
|
148
148
|
def _switch_tab(self) -> None:
|
|
149
|
-
# Close the outgoing tab's transient footer modes (e.g. the Sessions
|
|
150
|
-
# filter Edit) — they turn invisible once the next tab's hints replace
|
|
151
|
-
# the footer, but would keep eating keys after switching back.
|
|
152
|
-
self.views[self._active].deactivate()
|
|
153
149
|
self._active = (self._active + 1) % len(self.views)
|
|
154
150
|
self.body.original_widget = self.views[self._active].widget
|
|
155
151
|
self._update_tab_bar()
|
|
@@ -171,12 +167,19 @@ class App:
|
|
|
171
167
|
elif key in ("n", "esc"):
|
|
172
168
|
self._close_confirm()
|
|
173
169
|
return
|
|
170
|
+
view = self.views[self._active]
|
|
171
|
+
if view.captures_text():
|
|
172
|
+
# A text mode (e.g. the Sessions filter Edit) owns EVERY key:
|
|
173
|
+
# consuming tab/q here would switch tabs / quit mid-typing (the q
|
|
174
|
+
# in "sql" used to exit csctl).
|
|
175
|
+
view.handle_key(key)
|
|
176
|
+
return
|
|
174
177
|
if key == "tab":
|
|
175
178
|
self._switch_tab()
|
|
176
179
|
elif key == "q":
|
|
177
180
|
self._exit()
|
|
178
181
|
else:
|
|
179
|
-
|
|
182
|
+
view.handle_key(key)
|
|
180
183
|
|
|
181
184
|
def confirm(self, message: str, on_yes: Callable[[], None]) -> None:
|
|
182
185
|
"""Show a confirm modal over the active tab; run `on_yes` on Enter/y.
|
|
@@ -232,8 +235,13 @@ class App:
|
|
|
232
235
|
self._exit(intent)
|
|
233
236
|
|
|
234
237
|
def set_hints(self, hints: str) -> None:
|
|
235
|
-
"""Footer = shared prefix + the active tab's keyhints (D1 single source).
|
|
236
|
-
|
|
238
|
+
"""Footer = shared prefix + the active tab's keyhints (D1 single source).
|
|
239
|
+
|
|
240
|
+
While the active view captures text, the prefix's Tab/q/r promises are
|
|
241
|
+
all false (every key goes to the text widget), so only the mode's own
|
|
242
|
+
hints are shown."""
|
|
243
|
+
prefix = "" if self.views[self._active].captures_text() else FOOTER_PREFIX
|
|
244
|
+
self.footer_text.set_text(prefix + hints)
|
|
237
245
|
|
|
238
246
|
# --- the view-facing façade (views call these, never App internals) ---
|
|
239
247
|
|
|
@@ -242,14 +250,6 @@ class App:
|
|
|
242
250
|
may ask about tab state (no peeking at `views`/`_active`)."""
|
|
243
251
|
return self.views[self._active] is view
|
|
244
252
|
|
|
245
|
-
def own_footer(self, widget: urwid.Widget) -> None:
|
|
246
|
-
"""Hand the footer to a view's transient widget (e.g. the filter Edit)."""
|
|
247
|
-
self.frame.footer = widget
|
|
248
|
-
|
|
249
|
-
def release_footer(self) -> None:
|
|
250
|
-
"""Give the footer back to the standard hints line."""
|
|
251
|
-
self._restore_footer()
|
|
252
|
-
|
|
253
253
|
def notify(self, msg: str, seconds: float = 3) -> None:
|
|
254
254
|
# The newest notification owns the footer: cancel the previous restore
|
|
255
255
|
# alarm so an older timer can't clear this message early.
|
|
@@ -138,10 +138,10 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
138
138
|
def _cmd_prune(args: argparse.Namespace) -> None:
|
|
139
139
|
from .data.cleanup import (
|
|
140
140
|
cleanup_stats,
|
|
141
|
+
execute_orphan_removals,
|
|
142
|
+
execute_session_removals,
|
|
141
143
|
list_orphan_dirs,
|
|
142
144
|
prune_sessions,
|
|
143
|
-
remove_orphan_dirs,
|
|
144
|
-
remove_session,
|
|
145
145
|
)
|
|
146
146
|
from .data.sessions import scan
|
|
147
147
|
|
|
@@ -155,7 +155,10 @@ def _cmd_prune(args: argparse.Namespace) -> None:
|
|
|
155
155
|
if not args.apply:
|
|
156
156
|
print("Dry run. Add --apply to execute.")
|
|
157
157
|
return
|
|
158
|
-
|
|
158
|
+
# Deletes AT MOST the listed entries, revalidated against fresh
|
|
159
|
+
# protection data (删除 ⊆ 预览 — same executor as the TUI; `sessions`
|
|
160
|
+
# feeds the transcript tier of the protection set).
|
|
161
|
+
count = execute_orphan_removals(orphans, sessions=sessions)
|
|
159
162
|
print(f"Swept {count} orphan dir(s).")
|
|
160
163
|
return
|
|
161
164
|
|
|
@@ -174,9 +177,8 @@ def _cmd_prune(args: argparse.Namespace) -> None:
|
|
|
174
177
|
print("Dry run. Add --apply to execute.")
|
|
175
178
|
return
|
|
176
179
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
print(f"Pruned {len(targets)} session(s).")
|
|
180
|
+
count = execute_session_removals(targets)
|
|
181
|
+
print(f"Pruned {count} session(s).")
|
|
180
182
|
|
|
181
183
|
|
|
182
184
|
def _cmd_prune_zombies(args: argparse.Namespace) -> None:
|
|
@@ -184,12 +186,12 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
|
|
|
184
186
|
|
|
185
187
|
Reuses the already-gated `data/cleanup` helpers: `select_zombie_pids` keeps
|
|
186
188
|
the current session's pid and any alive pid of a resumed multi-pid sid, and
|
|
187
|
-
`
|
|
189
|
+
`execute_zombie_removals` refuses without `/proc`. The dry-run preview is
|
|
188
190
|
gated here too — off `/proc` every pid looks dead, so `current` can't be
|
|
189
191
|
determined and we must not even claim the files are sweepable (R10).
|
|
190
192
|
"""
|
|
191
193
|
from .data import liveness, proc
|
|
192
|
-
from .data.cleanup import
|
|
194
|
+
from .data.cleanup import execute_zombie_removals, select_zombie_pids
|
|
193
195
|
|
|
194
196
|
if not proc.current_determinable():
|
|
195
197
|
print("Refused: '/proc' unavailable — cannot determine the current session (R10).")
|
|
@@ -201,25 +203,25 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
|
|
|
201
203
|
if not args.apply:
|
|
202
204
|
print("Dry run. Add --apply to execute.")
|
|
203
205
|
return
|
|
204
|
-
count =
|
|
206
|
+
count = execute_zombie_removals(zombies, session_procs=procs, cur=cur)
|
|
205
207
|
print(f"Swept {count} zombie session file(s).")
|
|
206
208
|
|
|
207
209
|
|
|
208
210
|
def _cmd_prune_aged(args: argparse.Namespace) -> None:
|
|
209
211
|
"""Strategy B age sweep of time/global-keyed dirs (R7.2) via the CLI.
|
|
210
212
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
+
The age sweep is mtime-only and session-agnostic, so (unlike the zombie
|
|
214
|
+
sweep) it is not gated on `/proc`.
|
|
213
215
|
"""
|
|
214
216
|
from .config import cfg
|
|
215
|
-
from .data.cleanup import
|
|
217
|
+
from .data.cleanup import execute_aged_removals, list_aged_entries
|
|
216
218
|
|
|
217
219
|
aged = list_aged_entries()
|
|
218
220
|
print(f"Would sweep {len(aged)} aged entr(y/ies) older than {cfg.cleanup_age_days}d")
|
|
219
221
|
if not args.apply:
|
|
220
222
|
print("Dry run. Add --apply to execute.")
|
|
221
223
|
return
|
|
222
|
-
count =
|
|
224
|
+
count = execute_aged_removals(aged)
|
|
223
225
|
print(f"Swept {count} aged entr(y/ies).")
|
|
224
226
|
|
|
225
227
|
|
|
@@ -247,16 +249,15 @@ def _cmd_skill(args: argparse.Namespace) -> None:
|
|
|
247
249
|
|
|
248
250
|
|
|
249
251
|
def _cmd_agents(args: argparse.Namespace) -> None:
|
|
250
|
-
from .
|
|
252
|
+
from .data.liveness import enrich_jobs
|
|
251
253
|
from .data.registry import read_agent_jobs
|
|
252
254
|
|
|
253
|
-
jobs = read_agent_jobs(max_age=0.0)
|
|
255
|
+
jobs = enrich_jobs(read_agent_jobs(max_age=0.0))
|
|
254
256
|
if not jobs:
|
|
255
257
|
print("No background agents found.")
|
|
256
258
|
return
|
|
257
259
|
for job in jobs:
|
|
258
|
-
|
|
259
|
-
state = "live" if alive else (job.state or "settled")
|
|
260
|
+
state = "live" if job.host_alive else (job.state or "settled")
|
|
260
261
|
tempo = job.tempo or "-"
|
|
261
262
|
name = job.name or job.short
|
|
262
263
|
print(f" {job.short} [{state}] tempo={tempo} {name} {job.cwd}")
|
|
@@ -266,25 +267,17 @@ def _cmd_env(args: argparse.Namespace) -> None:
|
|
|
266
267
|
from .data import environments, rc
|
|
267
268
|
|
|
268
269
|
# Scan RC servers so the env_* namespace is covered too (it has no state
|
|
269
|
-
# file — only a running server references it).
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# Record every file-referenced env so a later run (after RC toggled off / a job
|
|
276
|
-
# removed) reports it as an orphan = ledger − file-referenced (R6 persistence).
|
|
277
|
-
environments.upsert(file_referenced)
|
|
278
|
-
current = environments.current_envs(observed)
|
|
279
|
-
orphans = environments.orphan_envs(file_referenced)
|
|
280
|
-
|
|
281
|
-
print(f"Current bridge environments: {len(current)}")
|
|
282
|
-
for e in current:
|
|
270
|
+
# file — only a running server references it). The whole observe → upsert →
|
|
271
|
+
# classify pipeline (and its ordering invariant) lives in reconcile.
|
|
272
|
+
recon = environments.reconcile(rc_servers=rc.scan_servers(), max_age=0.0)
|
|
273
|
+
|
|
274
|
+
print(f"Current bridge environments: {len(recon.current)}")
|
|
275
|
+
for e in recon.current:
|
|
283
276
|
print(f" {e.env_id} sid={e.bound_sid or '-'}")
|
|
284
277
|
|
|
285
|
-
print(f"Orphan environments (delete manually on claude.ai/code): {len(orphans)}")
|
|
286
|
-
for
|
|
287
|
-
print(f" {
|
|
278
|
+
print(f"Orphan environments (delete manually on claude.ai/code): {len(recon.orphans)}")
|
|
279
|
+
for e in recon.orphans:
|
|
280
|
+
print(f" {e.env_id} sid={e.bound_sid or '-'}")
|
|
288
281
|
|
|
289
282
|
print(
|
|
290
283
|
"Note: csctl cannot deregister cloud environments; "
|