cc-session-control 0.6.1__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.1/src/cc_session_control.egg-info → cc_session_control-0.6.2}/PKG-INFO +1 -1
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/actions/agent_ops.py +2 -28
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/actions/session_ops.py +67 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/app.py +28 -17
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/cli.py +10 -37
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/cleanup.py +2 -14
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/environments.py +62 -58
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/liveness.py +21 -4
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/rc.py +3 -10
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/registry.py +2 -9
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/sessions.py +3 -8
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/snapshot.py +1 -4
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/models.py +19 -0
- 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.1 → cc_session_control-0.6.2}/src/cc_session_control/views/_sessions_cleanup.py +1 -4
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/agents.py +76 -104
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/rc.py +151 -127
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/sessions.py +175 -210
- {cc_session_control-0.6.1 → cc_session_control-0.6.2/src/cc_session_control.egg-info}/PKG-INFO +1 -1
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/SOURCES.txt +3 -1
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_agent_ops.py +9 -4
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_agents.py +21 -11
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_liveness.py +32 -18
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_rc.py +21 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_registry.py +14 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_sessions.py +4 -4
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_views.py +74 -21
- cc_session_control-0.6.1/src/cc_session_control/data/agents.py +0 -12
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/LICENSE +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/README.md +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/pyproject.toml +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/setup.cfg +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/config.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/skill/SKILL.md +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/views/_session_row.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_app.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_cleanup.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_cli.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_data.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_environments.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_proc.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_skill_ops.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_smoke.py +0 -0
- {cc_session_control-0.6.1 → cc_session_control-0.6.2}/tests/test_snapshot.py +0 -0
{cc_session_control-0.6.1 → 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.1 → 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
|
|
@@ -88,7 +89,7 @@ def _make_screen() -> urwid.raw_display.Screen:
|
|
|
88
89
|
|
|
89
90
|
class App:
|
|
90
91
|
def __init__(self) -> None:
|
|
91
|
-
self.result:
|
|
92
|
+
self.result: object | None = None # a session_ops.ExitIntent, or None
|
|
92
93
|
self._exiting = False
|
|
93
94
|
self._alarm_handle: object | None = None
|
|
94
95
|
self._notify_alarm: object | None = None
|
|
@@ -210,32 +211,42 @@ class App:
|
|
|
210
211
|
self._confirm_yes = None
|
|
211
212
|
self.set_hints(self.views[self._active].keyhints())
|
|
212
213
|
|
|
213
|
-
def _exit(self, result:
|
|
214
|
+
def _exit(self, result: object = None) -> None:
|
|
214
215
|
self._exiting = True
|
|
215
216
|
if self._alarm_handle:
|
|
216
217
|
self.loop.remove_alarm(self._alarm_handle)
|
|
217
218
|
self.result = result
|
|
218
219
|
raise urwid.ExitMainLoop()
|
|
219
220
|
|
|
220
|
-
def
|
|
221
|
-
|
|
221
|
+
def exit_with(self, intent: object) -> None:
|
|
222
|
+
"""Exit the MainLoop carrying a `session_ops.ExitIntent`.
|
|
222
223
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
self._exit(("tmux_resume", session))
|
|
230
|
-
|
|
231
|
-
def exit_with_tmux_new(self, directory: str) -> None:
|
|
232
|
-
"""`t` on a project row: start a NEW claude session in tmux, then enter."""
|
|
233
|
-
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)
|
|
234
230
|
|
|
235
231
|
def set_hints(self, hints: str) -> None:
|
|
236
232
|
"""Footer = shared prefix + the active tab's keyhints (D1 single source)."""
|
|
237
233
|
self.footer_text.set_text(FOOTER_PREFIX + hints)
|
|
238
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
|
+
|
|
239
250
|
def notify(self, msg: str, seconds: float = 3) -> None:
|
|
240
251
|
# The newest notification owns the footer: cancel the previous restore
|
|
241
252
|
# alarm so an older timer can't clear this message early.
|
|
@@ -301,7 +312,7 @@ class App:
|
|
|
301
312
|
view.apply_data()
|
|
302
313
|
return True
|
|
303
314
|
|
|
304
|
-
def run(self) ->
|
|
315
|
+
def run(self) -> object | None:
|
|
305
316
|
self._pipe_fd = self.loop.watch_pipe(self._on_pipe)
|
|
306
317
|
self.views[self._active].load()
|
|
307
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.1 → 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.1 → 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.1 → 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
|
|
@@ -10,7 +10,7 @@ import subprocess
|
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
12
|
from ..config import cfg
|
|
13
|
-
from ..models import EnvRecord, RCProject, RCServer
|
|
13
|
+
from ..models import EnvRecord, RCProject, RCServer, split_env_id
|
|
14
14
|
from . import environments, proc
|
|
15
15
|
|
|
16
16
|
# Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
|
|
@@ -362,18 +362,11 @@ def scan() -> list[RCProject]:
|
|
|
362
362
|
auto_start=name in enabled,
|
|
363
363
|
rc_at_startup=_read_rc_at_startup(directory),
|
|
364
364
|
spawn_mode=_read_spawn_mode(name),
|
|
365
|
+
dir_exists=os.path.isdir(directory),
|
|
365
366
|
))
|
|
366
367
|
return result
|
|
367
368
|
|
|
368
369
|
|
|
369
|
-
def _split_env(env_id: str) -> tuple[str, str]:
|
|
370
|
-
"""`env_abc` -> (`env`, `abc`); ("", "") when not a namespaced id."""
|
|
371
|
-
prefix, sep, suffix = env_id.partition("_")
|
|
372
|
-
if not sep or not prefix or not suffix:
|
|
373
|
-
return "", ""
|
|
374
|
-
return prefix, suffix
|
|
375
|
-
|
|
376
|
-
|
|
377
370
|
def _capture_env_id(target: str) -> str:
|
|
378
371
|
"""Grep an `env_*` cloud id from a managed server's pane output, or "".
|
|
379
372
|
|
|
@@ -416,7 +409,7 @@ def scan_servers() -> list[RCServer]:
|
|
|
416
409
|
found = by_pid.get(pid)
|
|
417
410
|
env_id = _capture_env_id(target)
|
|
418
411
|
if env_id:
|
|
419
|
-
prefix, key =
|
|
412
|
+
prefix, key = split_env_id(env_id)
|
|
420
413
|
if prefix and key:
|
|
421
414
|
env_records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
422
415
|
servers.append(RCServer(
|
{cc_session_control-0.6.1 → cc_session_control-0.6.2}/src/cc_session_control/data/registry.py
RENAMED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import time
|
|
19
19
|
|
|
20
20
|
from ..config import cfg
|
|
21
|
-
from ..models import AgentJob, SessionProc
|
|
21
|
+
from ..models import AgentJob, SessionProc, split_env_id
|
|
22
22
|
|
|
23
23
|
_sessions_cache: list[SessionProc] | None = None
|
|
24
24
|
_sessions_time: float = 0.0
|
|
@@ -33,13 +33,6 @@ def invalidate_cache() -> None:
|
|
|
33
33
|
_jobs_cache = None
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
def _suffix(bridge: str | None) -> str:
|
|
37
|
-
"""Namespace suffix of a bridge id (`cse_abc` -> `abc`), or ""."""
|
|
38
|
-
if not bridge or "_" not in bridge:
|
|
39
|
-
return ""
|
|
40
|
-
return bridge.split("_", 1)[1]
|
|
41
|
-
|
|
42
|
-
|
|
43
36
|
def _parse_session_proc(path: str) -> SessionProc | None:
|
|
44
37
|
try:
|
|
45
38
|
with open(path, errors="ignore") as fh:
|
|
@@ -106,7 +99,7 @@ def _parse_agent_job(state_path: str) -> AgentJob | None:
|
|
|
106
99
|
tempo=d.get("tempo", "") or "",
|
|
107
100
|
cwd=d.get("cwd", "") or "",
|
|
108
101
|
name=d.get("name", "") or "",
|
|
109
|
-
env_suffix=
|
|
102
|
+
env_suffix=split_env_id(d.get("bridgeSessionId"))[1],
|
|
110
103
|
respawn_flags=[str(x) for x in flags],
|
|
111
104
|
)
|
|
112
105
|
|