cc-session-control 0.6.0__tar.gz → 0.6.2__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.0/src/cc_session_control.egg-info → cc_session_control-0.6.2}/PKG-INFO +3 -3
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/README.md +2 -2
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/agent_ops.py +2 -28
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/session_ops.py +67 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/app.py +57 -20
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/cli.py +10 -37
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/cleanup.py +2 -14
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/environments.py +62 -58
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/liveness.py +21 -4
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/rc.py +3 -10
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/registry.py +2 -9
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/sessions.py +3 -8
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/snapshot.py +1 -4
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/models.py +19 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/skill/SKILL.md +1 -1
- cc_session_control-0.6.2/src/cc_session_control/views/_base.py +130 -0
- cc_session_control-0.6.2/src/cc_session_control/views/_confirm.py +59 -0
- cc_session_control-0.6.2/src/cc_session_control/views/_keytable.py +51 -0
- cc_session_control-0.6.2/src/cc_session_control/views/_rows.py +41 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_session_row.py +2 -14
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_sessions_cleanup.py +7 -9
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/agents.py +79 -103
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/rc.py +156 -111
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/sessions.py +191 -202
- {cc_session_control-0.6.0 → cc_session_control-0.6.2/src/cc_session_control.egg-info}/PKG-INFO +3 -3
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/SOURCES.txt +4 -1
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_agent_ops.py +9 -4
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_agents.py +53 -12
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_app.py +49 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_liveness.py +32 -18
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_rc.py +21 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_registry.py +14 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_sessions.py +4 -4
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_skill_ops.py +12 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_views.py +164 -21
- cc_session_control-0.6.0/src/cc_session_control/data/agents.py +0 -12
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/LICENSE +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/pyproject.toml +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/setup.cfg +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_cleanup.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_cli.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_data.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_environments.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_smoke.py +0 -0
- {cc_session_control-0.6.0 → cc_session_control-0.6.2}/tests/test_snapshot.py +0 -0
{cc_session_control-0.6.0/src/cc_session_control.egg-info → cc_session_control-0.6.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cc-session-control
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: TUI manager for Claude Code sessions and Remote Control
|
|
5
5
|
Author: dzshzx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -29,9 +29,9 @@ 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, terminate, and delete Claude Code sessions across all projects
|
|
32
|
+
- **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
|
|
33
33
|
- **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
|
|
34
|
-
- **
|
|
34
|
+
- **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/).
|
|
37
37
|
|
|
@@ -6,9 +6,9 @@ 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, terminate, and delete Claude Code sessions across all projects
|
|
9
|
+
- **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
|
|
10
10
|
- **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
|
|
11
|
-
- **
|
|
11
|
+
- **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/).
|
|
14
14
|
|
{cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/agent_ops.py
RENAMED
|
@@ -25,34 +25,11 @@ import os
|
|
|
25
25
|
import shlex
|
|
26
26
|
import signal
|
|
27
27
|
import time
|
|
28
|
-
from dataclasses import replace
|
|
29
28
|
|
|
30
29
|
from ..config import cfg
|
|
31
30
|
from ..data import cleanup, liveness, proc, rc, registry
|
|
32
31
|
from ..models import AgentJob, Session
|
|
33
32
|
|
|
34
|
-
# --- User-facing labels/help (Simplified Chinese, read by the Phase 7 view) ---
|
|
35
|
-
|
|
36
|
-
TAKEOVER_LABEL = "接回"
|
|
37
|
-
|
|
38
|
-
# Unified verb table (matches Sessions/RC): Enter/o=接回(primary), s=停止(kill a
|
|
39
|
-
# live thing), d=删除, R=重启(respawn). `r`=刷新 lives in the App-level footer
|
|
40
|
-
# prefix now, so it is NOT repeated here; separators are ` · ` like the other tabs.
|
|
41
|
-
KEYHINTS = "Enter/o 接回 · s 停止 · d 删除 · w 查看 · R 重启"
|
|
42
|
-
|
|
43
|
-
# Orphan-process risk (R4.5 red line): stop only kills the host pid joined from
|
|
44
|
-
# the sessions registry, killing a --remote-control/bg worker does not always
|
|
45
|
-
# fully reap it, and a live worker with no sessions file can't be located at all.
|
|
46
|
-
HELP = (
|
|
47
|
-
"后台 agent 生命周期:\n"
|
|
48
|
-
" Enter/o 接回(拉回前台,复用 resume;接运行中的 agent 会先确认接管) w 查看 timeline(只读)\n"
|
|
49
|
-
" R 重启(respawn) d 删除(仅已结束) s 停止(仅运行中,需确认) r 刷新\n"
|
|
50
|
-
"停止/孤儿风险:停止只能杀经 sessions 文件 join 到的 host pid;"
|
|
51
|
-
"杀 --remote-control/后台 agent 不一定彻底回收,可能残留孤儿进程,需手动确认;"
|
|
52
|
-
"找不到运行中的后台 agent 的 host pid 时无法停止。"
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
33
|
# --- host-pid join (shared by stop_job, remove_job, and the view) -------------
|
|
57
34
|
|
|
58
35
|
def job_host(job: AgentJob) -> tuple[int | None, bool]:
|
|
@@ -68,10 +45,7 @@ def job_host(job: AgentJob) -> tuple[int | None, bool]:
|
|
|
68
45
|
Injects `/proc` liveness onto the registry rows, then defers to the single
|
|
69
46
|
pure join `registry.host_pid_for_sid` (shared with `snapshot._enrich_jobs`).
|
|
70
47
|
"""
|
|
71
|
-
procs =
|
|
72
|
-
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
73
|
-
for sp in registry.read_session_procs()
|
|
74
|
-
]
|
|
48
|
+
procs = liveness.live_session_procs()
|
|
75
49
|
return registry.host_pid_for_sid(job.sid, procs)
|
|
76
50
|
|
|
77
51
|
|
|
@@ -151,7 +125,7 @@ def resume_takeover(job: AgentJob) -> Session:
|
|
|
151
125
|
|
|
152
126
|
Bringing a bg session to the foreground is just a resume of its
|
|
153
127
|
`resume_sid`, so this returns a Session the view feeds to the SAME
|
|
154
|
-
`app.
|
|
128
|
+
`app.exit_with(ResumeIntent)` → `do_resume` pipeline used for foreground sessions —
|
|
155
129
|
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
156
130
|
`pid`/`alive` come from the host join so a live worker is killed first
|
|
157
131
|
(resume = takeover); `current` is computed so the launching session stays
|
{cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/actions/session_ops.py
RENAMED
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import shlex
|
|
7
7
|
import signal
|
|
8
8
|
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
|
|
10
11
|
from .. import clipboard
|
|
11
12
|
from ..data import proc, rc
|
|
@@ -202,6 +203,72 @@ def do_tmux_new(directory: str) -> str | None:
|
|
|
202
203
|
return rc.run_in_tmux(rc.session_name_for(directory), "claude", cmd)
|
|
203
204
|
|
|
204
205
|
|
|
206
|
+
# --- exit intents (the payload crossing the exit-then-exec seam) ------------
|
|
207
|
+
#
|
|
208
|
+
# The TUI cannot run `claude` (or replace itself with tmux) inside the urwid
|
|
209
|
+
# loop, so a resume-family action exits the MainLoop carrying ONE of these
|
|
210
|
+
# intents and `cli._cmd_tui` calls `intent.run()` afterwards. Each intent owns
|
|
211
|
+
# its own finalizer + failure messages, so adding a variant means adding one
|
|
212
|
+
# class here + one view call — app.py and cli.py stay untouched.
|
|
213
|
+
|
|
214
|
+
class ExitIntent:
|
|
215
|
+
"""What a view asks csctl to do AFTER the MainLoop exits."""
|
|
216
|
+
|
|
217
|
+
def run(self) -> None:
|
|
218
|
+
"""Finalize outside the loop; may exec-replace the csctl process."""
|
|
219
|
+
raise NotImplementedError
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class ResumeIntent(ExitIntent):
|
|
224
|
+
"""Enter/f: bare-terminal resume (execvp; takeover kill inside do_resume)."""
|
|
225
|
+
|
|
226
|
+
session: Session
|
|
227
|
+
fork: bool = False
|
|
228
|
+
|
|
229
|
+
def run(self) -> None:
|
|
230
|
+
do_resume(self.session, fork=self.fork)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class AttachIntent(ExitIntent):
|
|
235
|
+
"""`t` on a tmux-hosted session: enter its window in place (no kill)."""
|
|
236
|
+
|
|
237
|
+
target: str
|
|
238
|
+
|
|
239
|
+
def run(self) -> None:
|
|
240
|
+
if not enter_window(self.target):
|
|
241
|
+
print(f"Failed to enter tmux window {self.target} (is tmux running?)")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class TmuxResumeIntent(ExitIntent):
|
|
246
|
+
"""`t` on a dead / bare-terminal session: resume inside tmux, then enter."""
|
|
247
|
+
|
|
248
|
+
session: Session
|
|
249
|
+
|
|
250
|
+
def run(self) -> None:
|
|
251
|
+
target = do_tmux_resume(self.session)
|
|
252
|
+
if target is None:
|
|
253
|
+
print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
|
|
254
|
+
elif not enter_window(target):
|
|
255
|
+
print(f"Session resumed in tmux window {target}, but attaching failed.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class TmuxNewIntent(ExitIntent):
|
|
260
|
+
"""项目-tab `t`: start a NEW claude in tmux, then enter (pure spawn)."""
|
|
261
|
+
|
|
262
|
+
directory: str
|
|
263
|
+
|
|
264
|
+
def run(self) -> None:
|
|
265
|
+
target = do_tmux_new(self.directory)
|
|
266
|
+
if target is None:
|
|
267
|
+
print("Failed to start a new session inside tmux (is tmux available?).")
|
|
268
|
+
elif not enter_window(target):
|
|
269
|
+
print(f"Session started in tmux window {target}, but attaching failed.")
|
|
270
|
+
|
|
271
|
+
|
|
205
272
|
def enter_window(target: str) -> bool:
|
|
206
273
|
"""Bring `target` ("session:window") to the user's terminal foreground.
|
|
207
274
|
|
|
@@ -30,7 +30,8 @@ class TabView(Protocol):
|
|
|
30
30
|
widgets; `apply_data()` runs on the main loop and swaps `_pending` into the
|
|
31
31
|
walker. The `snapshot` is the shared per-cycle world (R11/D8); it is OPTIONAL
|
|
32
32
|
— a view called with `None` self-fetches (back-compat / tests). Adding a tab
|
|
33
|
-
means honoring every member below.
|
|
33
|
+
means honoring every member below; subclass `views/_base.py::ListTabView`
|
|
34
|
+
for the shared walker/overlay/footer plumbing instead of re-writing it.
|
|
34
35
|
"""
|
|
35
36
|
|
|
36
37
|
widget: urwid.Widget
|
|
@@ -41,6 +42,7 @@ class TabView(Protocol):
|
|
|
41
42
|
def apply_data(self) -> None: ...
|
|
42
43
|
def keyhints(self) -> str: ...
|
|
43
44
|
def handle_key(self, key: str) -> None: ...
|
|
45
|
+
def deactivate(self) -> None: ... # tab switch-away: close transient FOOTER modes
|
|
44
46
|
|
|
45
47
|
# 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
|
|
46
48
|
# ONE semantic set — views reference these names only (no per-tab duplicates;
|
|
@@ -87,9 +89,10 @@ def _make_screen() -> urwid.raw_display.Screen:
|
|
|
87
89
|
|
|
88
90
|
class App:
|
|
89
91
|
def __init__(self) -> None:
|
|
90
|
-
self.result:
|
|
92
|
+
self.result: object | None = None # a session_ops.ExitIntent, or None
|
|
91
93
|
self._exiting = False
|
|
92
94
|
self._alarm_handle: object | None = None
|
|
95
|
+
self._notify_alarm: object | None = None
|
|
93
96
|
self._pipe_fd: int | None = None
|
|
94
97
|
self._refreshing = False
|
|
95
98
|
# When set, a confirm modal is up: `_input` routes Enter/y/n/esc here and
|
|
@@ -140,6 +143,10 @@ class App:
|
|
|
140
143
|
self.header.contents[1] = (tab_bar, self.header.options())
|
|
141
144
|
|
|
142
145
|
def _switch_tab(self) -> None:
|
|
146
|
+
# Close the outgoing tab's transient footer modes (e.g. the Sessions
|
|
147
|
+
# filter Edit) — they turn invisible once the next tab's hints replace
|
|
148
|
+
# the footer, but would keep eating keys after switching back.
|
|
149
|
+
self.views[self._active].deactivate()
|
|
143
150
|
self._active = (self._active + 1) % len(self.views)
|
|
144
151
|
self.body.original_widget = self.views[self._active].widget
|
|
145
152
|
self._update_tab_bar()
|
|
@@ -181,10 +188,19 @@ class App:
|
|
|
181
188
|
self._confirm_base = self.body.original_widget
|
|
182
189
|
text = urwid.Text(f" {message}\n\n Enter / y 确认 n / Esc 取消")
|
|
183
190
|
box = urwid.AttrMap(urwid.LineBox(urwid.Filler(text)), "notify")
|
|
191
|
+
# Size to the content: the old fixed 50%×7 clipped long labels and the
|
|
192
|
+
# n/Esc line on narrow terminals. Width floors at 46 cells (capped to
|
|
193
|
+
# the screen); height = wrapped text rows + the LineBox border.
|
|
194
|
+
try:
|
|
195
|
+
cols, rows = self._screen.get_cols_rows()
|
|
196
|
+
except Exception:
|
|
197
|
+
cols, rows = 80, 24
|
|
198
|
+
width = min(max(cols // 2, 46), cols)
|
|
199
|
+
height = min(text.rows((max(width - 2, 1),)) + 2, max(rows - 2, 3))
|
|
184
200
|
self.body.original_widget = urwid.Overlay(
|
|
185
201
|
box, self._confirm_base,
|
|
186
|
-
align="center", width=
|
|
187
|
-
valign="middle", height=
|
|
202
|
+
align="center", width=width,
|
|
203
|
+
valign="middle", height=height,
|
|
188
204
|
)
|
|
189
205
|
self.footer_text.set_text(" Enter/y 确认 · n/Esc 取消")
|
|
190
206
|
|
|
@@ -195,37 +211,52 @@ class App:
|
|
|
195
211
|
self._confirm_yes = None
|
|
196
212
|
self.set_hints(self.views[self._active].keyhints())
|
|
197
213
|
|
|
198
|
-
def _exit(self, result:
|
|
214
|
+
def _exit(self, result: object = None) -> None:
|
|
199
215
|
self._exiting = True
|
|
200
216
|
if self._alarm_handle:
|
|
201
217
|
self.loop.remove_alarm(self._alarm_handle)
|
|
202
218
|
self.result = result
|
|
203
219
|
raise urwid.ExitMainLoop()
|
|
204
220
|
|
|
205
|
-
def
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def exit_with_attach(self, target: str) -> None:
|
|
209
|
-
"""`t` on a session already living in a tmux window: enter it in place."""
|
|
210
|
-
self._exit(("attach", target))
|
|
221
|
+
def exit_with(self, intent: object) -> None:
|
|
222
|
+
"""Exit the MainLoop carrying a `session_ops.ExitIntent`.
|
|
211
223
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
self._exit(("tmux_new", directory))
|
|
224
|
+
Views construct the intent (they know the verb); `cli._cmd_tui` calls
|
|
225
|
+
`intent.run()` after the loop ends — resume must exec OUTSIDE urwid, so
|
|
226
|
+
the finalizer cannot run here. App stays variant-agnostic: adding a
|
|
227
|
+
resume variant touches only the intent class + the view key handler.
|
|
228
|
+
"""
|
|
229
|
+
self._exit(intent)
|
|
219
230
|
|
|
220
231
|
def set_hints(self, hints: str) -> None:
|
|
221
232
|
"""Footer = shared prefix + the active tab's keyhints (D1 single source)."""
|
|
222
233
|
self.footer_text.set_text(FOOTER_PREFIX + hints)
|
|
223
234
|
|
|
235
|
+
# --- the view-facing façade (views call these, never App internals) ---
|
|
236
|
+
|
|
237
|
+
def is_active(self, view: TabView) -> bool:
|
|
238
|
+
"""Whether `view` is the tab currently shown — the one question views
|
|
239
|
+
may ask about tab state (no peeking at `views`/`_active`)."""
|
|
240
|
+
return self.views[self._active] is view
|
|
241
|
+
|
|
242
|
+
def own_footer(self, widget: urwid.Widget) -> None:
|
|
243
|
+
"""Hand the footer to a view's transient widget (e.g. the filter Edit)."""
|
|
244
|
+
self.frame.footer = widget
|
|
245
|
+
|
|
246
|
+
def release_footer(self) -> None:
|
|
247
|
+
"""Give the footer back to the standard hints line."""
|
|
248
|
+
self._restore_footer()
|
|
249
|
+
|
|
224
250
|
def notify(self, msg: str, seconds: float = 3) -> None:
|
|
251
|
+
# The newest notification owns the footer: cancel the previous restore
|
|
252
|
+
# alarm so an older timer can't clear this message early.
|
|
253
|
+
if self._notify_alarm is not None:
|
|
254
|
+
self.loop.remove_alarm(self._notify_alarm)
|
|
225
255
|
self.frame.footer = urwid.AttrMap(urwid.Text(f" {msg}"), "notify")
|
|
226
|
-
self.loop.set_alarm_in(seconds, lambda *_: self._restore_footer())
|
|
256
|
+
self._notify_alarm = self.loop.set_alarm_in(seconds, lambda *_: self._restore_footer())
|
|
227
257
|
|
|
228
258
|
def _restore_footer(self) -> None:
|
|
259
|
+
self._notify_alarm = None
|
|
229
260
|
self.frame.footer = self.footer
|
|
230
261
|
|
|
231
262
|
def _run_fetch_cycle(self) -> None:
|
|
@@ -245,6 +276,12 @@ class App:
|
|
|
245
276
|
for v in self.views:
|
|
246
277
|
v.fetch_pending(snapshot)
|
|
247
278
|
|
|
279
|
+
def refresh_with_notice(self) -> None:
|
|
280
|
+
"""Refresh + the standard footer notice — the single body of the
|
|
281
|
+
footer-prefix promise `r 刷新` (list AND modal modes, every tab)."""
|
|
282
|
+
self.trigger_async_refresh()
|
|
283
|
+
self.notify("刷新中…")
|
|
284
|
+
|
|
248
285
|
def trigger_async_refresh(self) -> None:
|
|
249
286
|
if self._refreshing or self._exiting:
|
|
250
287
|
return
|
|
@@ -275,7 +312,7 @@ class App:
|
|
|
275
312
|
view.apply_data()
|
|
276
313
|
return True
|
|
277
314
|
|
|
278
|
-
def run(self) ->
|
|
315
|
+
def run(self) -> object | None:
|
|
279
316
|
self._pipe_fd = self.loop.watch_pipe(self._on_pipe)
|
|
280
317
|
self.views[self._active].load()
|
|
281
318
|
self.set_hints(self.views[self._active].keyhints())
|
|
@@ -87,7 +87,8 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
87
87
|
for p in projects:
|
|
88
88
|
icon = {"running": "[running]", "dead": "[dead ]", "stopped": "[stopped]"}.get(p.status, p.status)
|
|
89
89
|
auto = "auto" if p.auto_start else " "
|
|
90
|
-
|
|
90
|
+
missing = "" if p.dir_exists else " (directory missing)"
|
|
91
|
+
print(f" {icon} {auto} {p.name}{missing}")
|
|
91
92
|
|
|
92
93
|
elif sub == "add":
|
|
93
94
|
proj = args.project
|
|
@@ -187,18 +188,13 @@ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
|
|
|
187
188
|
gated here too — off `/proc` every pid looks dead, so `current` can't be
|
|
188
189
|
determined and we must not even claim the files are sweepable (R10).
|
|
189
190
|
"""
|
|
190
|
-
from
|
|
191
|
-
|
|
192
|
-
from .data import proc, registry
|
|
191
|
+
from .data import liveness, proc
|
|
193
192
|
from .data.cleanup import remove_zombie_session_files, select_zombie_pids
|
|
194
193
|
|
|
195
194
|
if not proc.current_determinable():
|
|
196
195
|
print("Refused: '/proc' unavailable — cannot determine the current session (R10).")
|
|
197
196
|
return
|
|
198
|
-
procs =
|
|
199
|
-
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
200
|
-
for sp in registry.read_session_procs(max_age=0.0)
|
|
201
|
-
]
|
|
197
|
+
procs = liveness.live_session_procs(max_age=0.0)
|
|
202
198
|
cur = proc.ancestor_pids()
|
|
203
199
|
zombies = select_zombie_pids(procs, cur)
|
|
204
200
|
print(f"Would sweep {len(zombies)} zombie session file(s)")
|
|
@@ -298,38 +294,15 @@ def _cmd_env(args: argparse.Namespace) -> None:
|
|
|
298
294
|
|
|
299
295
|
|
|
300
296
|
def _cmd_tui(args: argparse.Namespace) -> None:
|
|
301
|
-
from .actions.session_ops import
|
|
297
|
+
from .actions.session_ops import ExitIntent
|
|
302
298
|
from .app import App
|
|
303
299
|
|
|
304
|
-
|
|
305
|
-
result = app.run()
|
|
300
|
+
result = App().run()
|
|
306
301
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if result
|
|
310
|
-
|
|
311
|
-
do_resume(session, fork=fork)
|
|
312
|
-
elif result[0] == "attach":
|
|
313
|
-
# `t` on a tmux-hosted session: enter its window (exec replaces csctl
|
|
314
|
-
# outside tmux; switch-client + normal exit inside).
|
|
315
|
-
if not enter_window(result[1]):
|
|
316
|
-
print(f"Failed to enter tmux window {result[1]} (is tmux running?)")
|
|
317
|
-
elif result[0] == "tmux_resume":
|
|
318
|
-
# `t` on a dead / bare-terminal session: spawn the resume window, then
|
|
319
|
-
# enter it. Kill semantics (takeover) are handled inside do_tmux_resume.
|
|
320
|
-
target = do_tmux_resume(result[1])
|
|
321
|
-
if target is None:
|
|
322
|
-
print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
|
|
323
|
-
elif not enter_window(target):
|
|
324
|
-
print(f"Session resumed in tmux window {target}, but attaching failed.")
|
|
325
|
-
elif result[0] == "tmux_new":
|
|
326
|
-
# `t` on a 项目-tab project row: start a NEW claude session in tmux,
|
|
327
|
-
# then enter it (nothing is killed — pure spawn).
|
|
328
|
-
target = do_tmux_new(result[1])
|
|
329
|
-
if target is None:
|
|
330
|
-
print("Failed to start a new session inside tmux (is tmux available?).")
|
|
331
|
-
elif not enter_window(target):
|
|
332
|
-
print(f"Session started in tmux window {target}, but attaching failed.")
|
|
302
|
+
# The intent finalizes itself outside the urwid loop (it may exec-replace
|
|
303
|
+
# csctl — resume/attach) and prints its own failure messages.
|
|
304
|
+
if isinstance(result, ExitIntent):
|
|
305
|
+
result.run()
|
|
333
306
|
|
|
334
307
|
|
|
335
308
|
def main() -> None:
|
{cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/cleanup.py
RENAMED
|
@@ -37,7 +37,6 @@ from __future__ import annotations
|
|
|
37
37
|
import os
|
|
38
38
|
import shutil
|
|
39
39
|
import time
|
|
40
|
-
from dataclasses import replace
|
|
41
40
|
|
|
42
41
|
from ..config import cfg
|
|
43
42
|
from ..models import AgentJob, Session, SessionProc
|
|
@@ -100,17 +99,6 @@ def _remove_path(path: str) -> bool:
|
|
|
100
99
|
|
|
101
100
|
# --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
|
|
102
101
|
|
|
103
|
-
def _live_session_procs(max_age: float = 5.0) -> list[SessionProc]:
|
|
104
|
-
"""Registry session files with `/proc` liveness injected (swallow-error)."""
|
|
105
|
-
try:
|
|
106
|
-
return [
|
|
107
|
-
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
108
|
-
for sp in registry.read_session_procs(max_age=max_age)
|
|
109
|
-
]
|
|
110
|
-
except Exception:
|
|
111
|
-
return []
|
|
112
|
-
|
|
113
|
-
|
|
114
102
|
def known_sids(
|
|
115
103
|
sessions: list[Session],
|
|
116
104
|
session_procs: list[SessionProc],
|
|
@@ -163,7 +151,7 @@ def _gather_known(
|
|
|
163
151
|
self-read swallows its own errors → safe empties.
|
|
164
152
|
"""
|
|
165
153
|
if session_procs is None:
|
|
166
|
-
session_procs =
|
|
154
|
+
session_procs = liveness.live_session_procs()
|
|
167
155
|
if agent_jobs is None:
|
|
168
156
|
try:
|
|
169
157
|
agent_jobs = registry.read_agent_jobs()
|
|
@@ -353,7 +341,7 @@ def remove_session(s: Session) -> bool:
|
|
|
353
341
|
if _remove_path(p):
|
|
354
342
|
removed = True
|
|
355
343
|
# M3: never delete a LIVE agent worker's jobs/<short> dir.
|
|
356
|
-
_, host_alive = registry.host_pid_for_sid(s.sid,
|
|
344
|
+
_, host_alive = registry.host_pid_for_sid(s.sid, liveness.live_session_procs())
|
|
357
345
|
if not host_alive and _remove_path(_jobs_path(s.sid)):
|
|
358
346
|
removed = True
|
|
359
347
|
return removed
|
{cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/environments.py
RENAMED
|
@@ -48,12 +48,18 @@ import contextlib
|
|
|
48
48
|
import json
|
|
49
49
|
import os
|
|
50
50
|
from collections.abc import Iterator
|
|
51
|
-
from dataclasses import replace
|
|
52
51
|
from typing import Any
|
|
53
52
|
|
|
54
53
|
from ..config import cfg
|
|
55
|
-
from ..models import
|
|
56
|
-
|
|
54
|
+
from ..models import (
|
|
55
|
+
AgentJob,
|
|
56
|
+
BridgeEnv,
|
|
57
|
+
EnvRecord,
|
|
58
|
+
RCServer,
|
|
59
|
+
SessionProc,
|
|
60
|
+
split_env_id,
|
|
61
|
+
)
|
|
62
|
+
from . import liveness, registry
|
|
57
63
|
|
|
58
64
|
try: # POSIX advisory locking; absent on Windows → degrade to no lock.
|
|
59
65
|
import fcntl
|
|
@@ -65,17 +71,51 @@ _RETENTION_SECONDS = 90 * 86400
|
|
|
65
71
|
_MAX_ENTRIES = 500
|
|
66
72
|
|
|
67
73
|
|
|
68
|
-
# ---
|
|
74
|
+
# --- observation builder (reads registry + liveness, never rc) -------------
|
|
69
75
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
def _collect(
|
|
77
|
+
session_procs: list[SessionProc],
|
|
78
|
+
agent_jobs: list[AgentJob],
|
|
79
|
+
rc_servers: list[RCServer] | None,
|
|
80
|
+
*,
|
|
81
|
+
alive_gated: bool,
|
|
82
|
+
) -> list[EnvRecord]:
|
|
83
|
+
"""The ONE observation walk over the three env sources (R6).
|
|
76
84
|
|
|
85
|
+
`alive_gated=False` → the FILE-REFERENCED membership set (any env a file
|
|
86
|
+
references, alive or zombie). `alive_gated=True` → the CURRENT set, each
|
|
87
|
+
source gated by its own liveness rule: `session_*` by the single
|
|
88
|
+
`liveness.is_rc_exposed` predicate, `cse_*` by a host-alive job or a
|
|
89
|
+
proc-alive session sharing the job sid, `env_*` by a running RC server.
|
|
90
|
+
"""
|
|
91
|
+
alive_sids = {sp.sid for sp in session_procs if sp.proc_alive}
|
|
92
|
+
records: list[EnvRecord] = []
|
|
93
|
+
for sp in session_procs:
|
|
94
|
+
if not sp.bridge:
|
|
95
|
+
continue
|
|
96
|
+
if alive_gated and not liveness.is_rc_exposed(sp.bridge, sp.proc_alive):
|
|
97
|
+
continue
|
|
98
|
+
prefix, key = split_env_id(sp.bridge)
|
|
99
|
+
if prefix and key:
|
|
100
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
|
|
101
|
+
for job in agent_jobs:
|
|
102
|
+
if not job.env_suffix:
|
|
103
|
+
continue
|
|
104
|
+
if alive_gated and not (job.host_alive or job.sid in alive_sids):
|
|
105
|
+
continue
|
|
106
|
+
records.append(
|
|
107
|
+
EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
|
|
108
|
+
)
|
|
109
|
+
for srv in rc_servers or []:
|
|
110
|
+
if not srv.env_id:
|
|
111
|
+
continue
|
|
112
|
+
if alive_gated and srv.status != "running":
|
|
113
|
+
continue
|
|
114
|
+
prefix, key = split_env_id(srv.env_id)
|
|
115
|
+
if prefix and key:
|
|
116
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
117
|
+
return records
|
|
77
118
|
|
|
78
|
-
# --- observation builder (reads registry only, never rc) -------------------
|
|
79
119
|
|
|
80
120
|
def observe(
|
|
81
121
|
session_procs: list[SessionProc] | None = None,
|
|
@@ -102,24 +142,7 @@ def observe(
|
|
|
102
142
|
session_procs = registry.read_session_procs(max_age=max_age)
|
|
103
143
|
if agent_jobs is None:
|
|
104
144
|
agent_jobs = registry.read_agent_jobs(max_age=max_age)
|
|
105
|
-
|
|
106
|
-
for sp in session_procs:
|
|
107
|
-
if not sp.bridge:
|
|
108
|
-
continue
|
|
109
|
-
prefix, key = _split_bridge(sp.bridge)
|
|
110
|
-
if prefix and key:
|
|
111
|
-
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
|
|
112
|
-
for job in agent_jobs:
|
|
113
|
-
if job.env_suffix:
|
|
114
|
-
records.append(
|
|
115
|
-
EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
|
|
116
|
-
)
|
|
117
|
-
for srv in rc_servers or []:
|
|
118
|
-
if srv.env_id:
|
|
119
|
-
prefix, sep, key = srv.env_id.partition("_")
|
|
120
|
-
if sep and prefix and key:
|
|
121
|
-
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
122
|
-
return records
|
|
145
|
+
return _collect(session_procs, agent_jobs, rc_servers, alive_gated=False)
|
|
123
146
|
except Exception:
|
|
124
147
|
return []
|
|
125
148
|
|
|
@@ -132,13 +155,14 @@ def observe_live(
|
|
|
132
155
|
) -> list[EnvRecord]:
|
|
133
156
|
"""Alive-gated "currently exposed" bridge envs (R3/R6) — the CURRENT set.
|
|
134
157
|
|
|
135
|
-
Where `observe()` is a bridge-truthy passive collector, this
|
|
136
|
-
`
|
|
137
|
-
its owner is alive — `session_*`
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the bound
|
|
158
|
+
Where `observe()` is a bridge-truthy passive collector, this applies the
|
|
159
|
+
single `liveness.is_rc_exposed` predicate (and the per-source gates in
|
|
160
|
+
`_collect`): a bridge is CURRENT only when its owner is alive — `session_*`
|
|
161
|
+
gated by proc-alive, `cse_*` by a proc-alive session sharing the job sid
|
|
162
|
+
(host-alive), `env_*` by a running RC server. So a zombie session's stale
|
|
163
|
+
`bridgeSessionId` is NOT reported as current — it falls through to
|
|
164
|
+
`orphan_envs` (a manual-delete candidate) instead of overstating the bound
|
|
165
|
+
count.
|
|
142
166
|
|
|
143
167
|
Pure when `session_procs`/`agent_jobs` are supplied (the snapshot path passes
|
|
144
168
|
its already-liveness-resolved data — DI for tests); reads the registry +
|
|
@@ -147,30 +171,10 @@ def observe_live(
|
|
|
147
171
|
"""
|
|
148
172
|
try:
|
|
149
173
|
if session_procs is None:
|
|
150
|
-
session_procs =
|
|
151
|
-
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
152
|
-
for sp in registry.read_session_procs(max_age=max_age)
|
|
153
|
-
]
|
|
174
|
+
session_procs = liveness.live_session_procs(max_age=max_age)
|
|
154
175
|
if agent_jobs is None:
|
|
155
176
|
agent_jobs = registry.read_agent_jobs(max_age=max_age)
|
|
156
|
-
|
|
157
|
-
records: list[EnvRecord] = []
|
|
158
|
-
for sp in session_procs:
|
|
159
|
-
if sp.bridge and sp.proc_alive:
|
|
160
|
-
prefix, key = _split_bridge(sp.bridge)
|
|
161
|
-
if prefix and key:
|
|
162
|
-
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
|
|
163
|
-
for job in agent_jobs:
|
|
164
|
-
if job.env_suffix and (job.host_alive or job.sid in alive_sids):
|
|
165
|
-
records.append(
|
|
166
|
-
EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
|
|
167
|
-
)
|
|
168
|
-
for srv in rc_servers or []:
|
|
169
|
-
if srv.env_id and srv.status == "running":
|
|
170
|
-
prefix, sep, key = srv.env_id.partition("_")
|
|
171
|
-
if sep and prefix and key:
|
|
172
|
-
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
173
|
-
return records
|
|
177
|
+
return _collect(session_procs, agent_jobs, rc_servers, alive_gated=True)
|
|
174
178
|
except Exception:
|
|
175
179
|
return []
|
|
176
180
|
|
{cc_session_control-0.6.0 → cc_session_control-0.6.2}/src/cc_session_control/data/liveness.py
RENAMED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Session liveness — the single authority.
|
|
2
2
|
|
|
3
|
-
Owns the ONE module-global cache for `claude agents --json
|
|
4
|
-
`agents.py` re-export shim so terminate's invalidate and scan's read share it).
|
|
3
|
+
Owns the ONE module-global cache for `claude agents --json`.
|
|
5
4
|
`live_index` is a pure merge of already-fetched liveness inputs (registry
|
|
6
5
|
session files with injected proc liveness + `claude agents --json`).
|
|
7
6
|
"""
|
|
@@ -12,9 +11,10 @@ import json
|
|
|
12
11
|
import subprocess
|
|
13
12
|
import time
|
|
14
13
|
from collections.abc import Callable
|
|
14
|
+
from dataclasses import replace
|
|
15
15
|
|
|
16
16
|
from ..models import LiveInfo, SessionProc
|
|
17
|
-
from . import proc
|
|
17
|
+
from . import proc, registry
|
|
18
18
|
|
|
19
19
|
_cache: dict[str, int | None] | None = None
|
|
20
20
|
_cache_time: float = 0.0
|
|
@@ -69,6 +69,23 @@ def invalidate_cache() -> None:
|
|
|
69
69
|
_cache = None
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def live_session_procs(max_age: float = 5.0) -> list[SessionProc]:
|
|
73
|
+
"""Registry session files with `/proc` liveness injected — THE assembly point.
|
|
74
|
+
|
|
75
|
+
`registry.read_session_procs` deliberately leaves `proc_alive=False` (pure
|
|
76
|
+
parse, no `/proc`); a `SessionProc.proc_alive` is only trustworthy after
|
|
77
|
+
this injection. Every consumer must come through here rather than re-inline
|
|
78
|
+
the `replace(sp, proc_alive=pid_alive(...))` idiom. Swallows errors → [].
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
return [
|
|
82
|
+
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
83
|
+
for sp in registry.read_session_procs(max_age=max_age)
|
|
84
|
+
]
|
|
85
|
+
except Exception:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
72
89
|
def _source_of(entrypoint: str, kind: str) -> str:
|
|
73
90
|
"""Coarse source bucket from the registry entrypoint/kind (D9)."""
|
|
74
91
|
if kind == "bg":
|
|
@@ -80,7 +97,7 @@ def _source_of(entrypoint: str, kind: str) -> str:
|
|
|
80
97
|
return "cli"
|
|
81
98
|
|
|
82
99
|
|
|
83
|
-
def
|
|
100
|
+
def is_rc_exposed(bridge: str | None, pid_alive: bool) -> bool:
|
|
84
101
|
"""Whether session remote control is CURRENTLY exposed (pure predicate).
|
|
85
102
|
|
|
86
103
|
Exposed iff the bridge id is a truthy string AND the owning process is still
|