cc-session-control 0.4.0__py3-none-any.whl
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/__init__.py +3 -0
- cc_session_control/__main__.py +5 -0
- cc_session_control/actions/__init__.py +0 -0
- cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control/actions/session_ops.py +150 -0
- cc_session_control/app.py +264 -0
- cc_session_control/cli.py +288 -0
- cc_session_control/clipboard.py +44 -0
- cc_session_control/config.py +132 -0
- cc_session_control/data/__init__.py +0 -0
- cc_session_control/data/agents.py +12 -0
- cc_session_control/data/cleanup.py +402 -0
- cc_session_control/data/environments.py +444 -0
- cc_session_control/data/liveness.py +140 -0
- cc_session_control/data/proc.py +214 -0
- cc_session_control/data/rc.py +411 -0
- cc_session_control/data/registry.py +155 -0
- cc_session_control/data/sessions.py +188 -0
- cc_session_control/data/snapshot.py +115 -0
- cc_session_control/models.py +170 -0
- cc_session_control/views/__init__.py +0 -0
- cc_session_control/views/_session_row.py +145 -0
- cc_session_control/views/agents.py +293 -0
- cc_session_control/views/rc.py +374 -0
- cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0.dist-info/METADATA +115 -0
- cc_session_control-0.4.0.dist-info/RECORD +31 -0
- cc_session_control-0.4.0.dist-info/WHEEL +5 -0
- cc_session_control-0.4.0.dist-info/entry_points.txt +2 -0
- cc_session_control-0.4.0.dist-info/licenses/LICENSE +21 -0
- cc_session_control-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""RC view — the 远程控制 tab.
|
|
2
|
+
|
|
3
|
+
Shows three things for one machine-wide Remote Control surface:
|
|
4
|
+
1. managed projects (RCProject) with the tri-state `remoteControlAtStartup`
|
|
5
|
+
and `remoteControlSpawnMode`, plus the existing start/stop/autostart keys;
|
|
6
|
+
2. project RC servers (RCServer) discovered via tmux ∪ /proc, badged
|
|
7
|
+
managed/external — external servers are READ-ONLY (no takeover/restart key);
|
|
8
|
+
3. the bridge-environment ledger (current vs orphan). Orphans are labelled
|
|
9
|
+
"云端需手动删除": csctl has NO local deregister — deletion is manual on
|
|
10
|
+
claude.ai/code (capability red line / AC9).
|
|
11
|
+
|
|
12
|
+
Only project rows are actionable. Server and environment rows are display-only,
|
|
13
|
+
so no key toggles RC on a running session, takes over an external server, or
|
|
14
|
+
deregisters a cloud environment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import urwid
|
|
22
|
+
|
|
23
|
+
from ..data import environments, rc
|
|
24
|
+
from ..data.rc import set_rc_at_startup
|
|
25
|
+
from ..models import BridgeEnv, RCProject, RCServer
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from ..data.snapshot import WorldSnapshot
|
|
29
|
+
|
|
30
|
+
from ..app import App
|
|
31
|
+
|
|
32
|
+
_STATUS_MAP = {"running": "● 运行中", "dead": "✖ 已退出", "stopped": "○ 已停止"}
|
|
33
|
+
_RC_TRISTATE = {True: "开", False: "关", None: "未设置"}
|
|
34
|
+
# `c` cycles the per-project remoteControlAtStartup tri-state in full so the user
|
|
35
|
+
# can return to an explicit True (the old 2-cycle could never set True again).
|
|
36
|
+
_NEXT_TRISTATE = {None: True, True: False, False: None}
|
|
37
|
+
|
|
38
|
+
# Literal required in the UI by AC9 — the manual-delete red line.
|
|
39
|
+
_MANUAL_DELETE = "云端需手动删除"
|
|
40
|
+
|
|
41
|
+
# Red-line #5 honesty: the orphan ledger can only see envs minted while csctl was
|
|
42
|
+
# running, and csctl can never deregister a cloud env. Surfaced in the env-ledger
|
|
43
|
+
# section AND the help so the incompleteness is never silently implied as complete.
|
|
44
|
+
_LEDGER_CAVEAT = (
|
|
45
|
+
" 注:孤儿清单不完整——csctl 未运行期间铸造的环境无法追踪;"
|
|
46
|
+
"且 csctl 不能注销云端环境,需在 claude.ai/code 手动删除。"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RCRow(urwid.WidgetWrap):
|
|
51
|
+
def __init__(self, project: RCProject) -> None:
|
|
52
|
+
self.project = project
|
|
53
|
+
status_text = _STATUS_MAP.get(project.status, project.status)
|
|
54
|
+
auto = "✓" if project.auto_start else "✗"
|
|
55
|
+
rc_at = _RC_TRISTATE.get(project.rc_at_startup, "未设置")
|
|
56
|
+
spawn = project.spawn_mode or "—"
|
|
57
|
+
name = project.name if project.in_list or project.status == "running" else f"({project.name})"
|
|
58
|
+
|
|
59
|
+
cols = urwid.Columns([
|
|
60
|
+
(10, urwid.Text(status_text)),
|
|
61
|
+
(8, urwid.Text(auto, align="center")),
|
|
62
|
+
(8, urwid.Text(rc_at, align="center")),
|
|
63
|
+
(10, urwid.Text(spawn, wrap="clip")),
|
|
64
|
+
("weight", 2, urwid.Text(name, wrap="clip")),
|
|
65
|
+
("weight", 3, urwid.Text(project.directory, wrap="clip")),
|
|
66
|
+
], min_width=6)
|
|
67
|
+
|
|
68
|
+
attr = "rc_running" if project.status == "running" else "rc_stopped"
|
|
69
|
+
mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
|
|
70
|
+
super().__init__(mapped)
|
|
71
|
+
|
|
72
|
+
def selectable(self) -> bool:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
76
|
+
return key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _DividerRow(urwid.WidgetWrap):
|
|
80
|
+
"""Non-selectable section separator (focus skips it)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, text: str) -> None:
|
|
83
|
+
super().__init__(urwid.AttrMap(urwid.Text(text), "col_header"))
|
|
84
|
+
|
|
85
|
+
def selectable(self) -> bool:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ServerRow(urwid.WidgetWrap):
|
|
90
|
+
"""A project RC server (managed/external) — display only, never actionable."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, server: RCServer) -> None:
|
|
93
|
+
self.server = server
|
|
94
|
+
status_text = _STATUS_MAP.get(server.status, server.status)
|
|
95
|
+
badge = "托管" if server.managed else "外部"
|
|
96
|
+
pid = str(server.pid) if server.pid else "-"
|
|
97
|
+
cols = urwid.Columns([
|
|
98
|
+
(10, urwid.Text(status_text)),
|
|
99
|
+
(8, urwid.Text(badge, align="center")),
|
|
100
|
+
(8, urwid.Text(pid, align="center")),
|
|
101
|
+
("weight", 2, urwid.Text(server.name, wrap="clip")),
|
|
102
|
+
("weight", 3, urwid.Text(server.cwd or "", wrap="clip")),
|
|
103
|
+
], min_width=6)
|
|
104
|
+
attr = "rc_running" if server.status == "running" else "rc_stopped"
|
|
105
|
+
mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
|
|
106
|
+
super().__init__(mapped)
|
|
107
|
+
|
|
108
|
+
def selectable(self) -> bool:
|
|
109
|
+
# P4: display-only — focus SKIPS it (like _DividerRow) so the user never
|
|
110
|
+
# lands on a highlighted row whose keys are all silently inert.
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
114
|
+
return key
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class EnvRow(urwid.WidgetWrap):
|
|
118
|
+
"""A bridge-environment ledger entry (current/orphan) — display only."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, env: BridgeEnv) -> None:
|
|
121
|
+
self.env = env
|
|
122
|
+
if env.status == "current":
|
|
123
|
+
mark = "● 绑定中"
|
|
124
|
+
hint = ""
|
|
125
|
+
else:
|
|
126
|
+
mark = "○ 孤儿"
|
|
127
|
+
hint = _MANUAL_DELETE
|
|
128
|
+
cols = urwid.Columns([
|
|
129
|
+
(10, urwid.Text(mark)),
|
|
130
|
+
("weight", 2, urwid.Text(env.env_id, wrap="clip")),
|
|
131
|
+
("weight", 2, urwid.Text(env.bound_sid or "-", wrap="clip")),
|
|
132
|
+
("weight", 2, urwid.Text(hint, wrap="clip")),
|
|
133
|
+
], min_width=6)
|
|
134
|
+
attr = "rc_running" if env.status == "current" else "rc_stopped"
|
|
135
|
+
mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
|
|
136
|
+
super().__init__(mapped)
|
|
137
|
+
|
|
138
|
+
def selectable(self) -> bool:
|
|
139
|
+
# P4: display-only ledger row — focus SKIPS it (csctl has no deregister).
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
143
|
+
return key
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RCView:
|
|
147
|
+
def __init__(self, app: App) -> None:
|
|
148
|
+
self.app = app
|
|
149
|
+
self._projects: list[RCProject] = []
|
|
150
|
+
self._servers: list[RCServer] = []
|
|
151
|
+
self._current: list[BridgeEnv] = []
|
|
152
|
+
self._orphans: list[BridgeEnv] = []
|
|
153
|
+
self._pending: list[RCProject] | None = None
|
|
154
|
+
self._pending_servers: list[RCServer] | None = None
|
|
155
|
+
self._pending_current: list[BridgeEnv] | None = None
|
|
156
|
+
self._pending_orphans: list[BridgeEnv] | None = None
|
|
157
|
+
self._loaded = False
|
|
158
|
+
self._help = False
|
|
159
|
+
|
|
160
|
+
self.status = urwid.AttrMap(urwid.Text(" 扫描中…"), "status")
|
|
161
|
+
col_header = urwid.AttrMap(urwid.Columns([
|
|
162
|
+
(10, urwid.Text("状态")),
|
|
163
|
+
(8, urwid.Text("开机自启", align="center")),
|
|
164
|
+
(8, urwid.Text("自动远控", align="center")),
|
|
165
|
+
(10, urwid.Text("启动模式")),
|
|
166
|
+
("weight", 2, urwid.Text("项目")),
|
|
167
|
+
("weight", 3, urwid.Text("目录")),
|
|
168
|
+
], min_width=6), "col_header")
|
|
169
|
+
self.walker = urwid.SimpleFocusListWalker([])
|
|
170
|
+
self.listbox = urwid.ListBox(self.walker)
|
|
171
|
+
body = urwid.AttrMap(self.listbox, {None: "body"})
|
|
172
|
+
self.widget = urwid.Frame(body, header=col_header, footer=self.status)
|
|
173
|
+
|
|
174
|
+
def keyhints(self) -> str:
|
|
175
|
+
if self._help:
|
|
176
|
+
return "按任意键返回"
|
|
177
|
+
return "Enter 启动 · s 停止 · a 开机自启 · c 自动远控 · A/S 批量 · ? 帮助"
|
|
178
|
+
|
|
179
|
+
def load(self) -> None:
|
|
180
|
+
self._projects = rc.scan()
|
|
181
|
+
self._servers, self._current, self._orphans = self._scan_extras()
|
|
182
|
+
self._loaded = True
|
|
183
|
+
self._rebuild()
|
|
184
|
+
|
|
185
|
+
def _scan_extras(self) -> tuple[list[RCServer], list[BridgeEnv], list[BridgeEnv]]:
|
|
186
|
+
"""Self-fetch the servers + environment ledger (no-snapshot path).
|
|
187
|
+
|
|
188
|
+
CURRENT uses the alive-gated `observe_live` so a zombie session's stale
|
|
189
|
+
bridge is not shown as bound (R3/R6); ORPHAN uses the bridge-truthy,
|
|
190
|
+
FILE-REFERENCED `observe` so it is precisely `ledger − file-referenced`
|
|
191
|
+
(an env still referenced by a file is NOT an orphan, even if its owner is
|
|
192
|
+
currently dead).
|
|
193
|
+
"""
|
|
194
|
+
servers = rc.scan_servers()
|
|
195
|
+
observed = environments.observe_live(rc_servers=servers)
|
|
196
|
+
file_referenced = environments.observe(rc_servers=servers)
|
|
197
|
+
return (
|
|
198
|
+
servers,
|
|
199
|
+
environments.current_envs(observed),
|
|
200
|
+
environments.orphan_envs(file_referenced),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None:
|
|
204
|
+
"""Worker-thread data fetch. Only sets pending fields — no widgets."""
|
|
205
|
+
if snapshot is not None:
|
|
206
|
+
self.set_pending(snapshot.rc_projects)
|
|
207
|
+
self._pending_servers = snapshot.rc_servers
|
|
208
|
+
self._pending_current = environments.current_envs(snapshot.observed_envs)
|
|
209
|
+
self._pending_orphans = environments.orphan_envs(snapshot.file_referenced_envs)
|
|
210
|
+
else:
|
|
211
|
+
self.set_pending(rc.scan())
|
|
212
|
+
servers, current, orphans = self._scan_extras()
|
|
213
|
+
self._pending_servers = servers
|
|
214
|
+
self._pending_current = current
|
|
215
|
+
self._pending_orphans = orphans
|
|
216
|
+
|
|
217
|
+
def set_pending(self, projects: list[RCProject]) -> None:
|
|
218
|
+
self._pending = projects
|
|
219
|
+
|
|
220
|
+
def apply_data(self) -> None:
|
|
221
|
+
if self._pending is not None:
|
|
222
|
+
self._projects = self._pending
|
|
223
|
+
self._pending = None
|
|
224
|
+
self._loaded = True
|
|
225
|
+
if self._pending_servers is not None:
|
|
226
|
+
self._servers = self._pending_servers
|
|
227
|
+
self._pending_servers = None
|
|
228
|
+
if self._pending_current is not None:
|
|
229
|
+
self._current = self._pending_current
|
|
230
|
+
self._pending_current = None
|
|
231
|
+
if self._pending_orphans is not None:
|
|
232
|
+
self._orphans = self._pending_orphans
|
|
233
|
+
self._pending_orphans = None
|
|
234
|
+
if not self._help:
|
|
235
|
+
self._rebuild()
|
|
236
|
+
|
|
237
|
+
def _rebuild(self) -> None:
|
|
238
|
+
focus_pos = self.walker.get_focus()[1] if self.walker else 0
|
|
239
|
+
self.walker.clear()
|
|
240
|
+
# Projects first, so default focus lands on an actionable row.
|
|
241
|
+
for p in self._projects:
|
|
242
|
+
self.walker.append(RCRow(p))
|
|
243
|
+
if self._servers:
|
|
244
|
+
self.walker.append(_DividerRow("── RC 服务(仅展示 · 托管见项目行 · 外部不可接管)──"))
|
|
245
|
+
for s in self._servers:
|
|
246
|
+
self.walker.append(ServerRow(s))
|
|
247
|
+
if self._current or self._orphans:
|
|
248
|
+
self.walker.append(_DividerRow(f"── 环境台账({_MANUAL_DELETE})──"))
|
|
249
|
+
self.walker.append(_DividerRow(_LEDGER_CAVEAT))
|
|
250
|
+
for e in self._current:
|
|
251
|
+
self.walker.append(EnvRow(e))
|
|
252
|
+
for e in self._orphans:
|
|
253
|
+
self.walker.append(EnvRow(e))
|
|
254
|
+
if not self.walker:
|
|
255
|
+
self.walker.append(urwid.AttrMap(urwid.Text(" 暂无远控项目"), "dead"))
|
|
256
|
+
if self.walker and focus_pos is not None:
|
|
257
|
+
self.walker.set_focus(min(focus_pos, len(self.walker) - 1))
|
|
258
|
+
|
|
259
|
+
running = sum(1 for p in self._projects if p.status == "running")
|
|
260
|
+
auto = sum(1 for p in self._projects if p.auto_start)
|
|
261
|
+
rc_off = sum(1 for p in self._projects if p.rc_at_startup is False)
|
|
262
|
+
rc_text = f" · 自动远控关 {rc_off}" if rc_off else ""
|
|
263
|
+
srv_text = f" · 服务 {len(self._servers)}" if self._servers else ""
|
|
264
|
+
env_text = f" · 孤儿环境 {len(self._orphans)}" if self._orphans else ""
|
|
265
|
+
self.status.original_widget.set_text(
|
|
266
|
+
f" 共 {len(self._projects)} 项目 · 运行 {running} · 开机自启 {auto}"
|
|
267
|
+
f"{rc_text}{srv_text}{env_text}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _selected(self) -> RCProject | None:
|
|
271
|
+
if not self.walker:
|
|
272
|
+
return None
|
|
273
|
+
widget = self.walker.get_focus()[0]
|
|
274
|
+
if isinstance(widget, RCRow):
|
|
275
|
+
return widget.project
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _update_footer(self) -> None:
|
|
279
|
+
if self.app.views[self.app._active] is not self:
|
|
280
|
+
return
|
|
281
|
+
self.app.set_hints(self.keyhints())
|
|
282
|
+
|
|
283
|
+
def handle_key(self, key: str) -> None:
|
|
284
|
+
if self._help:
|
|
285
|
+
self._help = False
|
|
286
|
+
self._rebuild()
|
|
287
|
+
self._update_footer()
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
p = self._selected()
|
|
291
|
+
|
|
292
|
+
if key == "enter" and p:
|
|
293
|
+
if not p.trusted:
|
|
294
|
+
self.app.notify("未信任 — 先在该目录跑一次 claude")
|
|
295
|
+
return
|
|
296
|
+
if p.status == "running":
|
|
297
|
+
self.app.notify("已在运行")
|
|
298
|
+
return
|
|
299
|
+
ok = rc.start_one(p.name)
|
|
300
|
+
self.app.notify(f"已启动 ws/{p.name}" if ok else "启动失败")
|
|
301
|
+
self.app.trigger_async_refresh()
|
|
302
|
+
elif key == "s" and p:
|
|
303
|
+
if p.status != "running":
|
|
304
|
+
self.app.notify("未在运行")
|
|
305
|
+
return
|
|
306
|
+
self.app.confirm(
|
|
307
|
+
f"停止远控服务「{p.name}」?将终止其进程。",
|
|
308
|
+
lambda: self._do_stop_one(p.name),
|
|
309
|
+
)
|
|
310
|
+
elif key == "a" and p:
|
|
311
|
+
new = rc.toggle_autostart(p.name)
|
|
312
|
+
self.app.notify(f"{p.name} 开机自启: {'开' if new else '关'}")
|
|
313
|
+
self.app.trigger_async_refresh()
|
|
314
|
+
elif key == "c" and p:
|
|
315
|
+
# Full 3-cycle so explicit True is reachable again: None→True→False→None.
|
|
316
|
+
new = _NEXT_TRISTATE[p.rc_at_startup]
|
|
317
|
+
set_rc_at_startup(p.directory, new)
|
|
318
|
+
self.app.notify(f"{p.name} 自动远控: {_RC_TRISTATE[new]}")
|
|
319
|
+
self.app.trigger_async_refresh()
|
|
320
|
+
elif key == "A":
|
|
321
|
+
count = rc.start_all_listed()
|
|
322
|
+
self.app.notify(f"已启动 {count} 个项目")
|
|
323
|
+
self.app.trigger_async_refresh()
|
|
324
|
+
elif key == "S":
|
|
325
|
+
if not any(p.status == "running" for p in self._projects):
|
|
326
|
+
self.app.notify("本来就没在跑")
|
|
327
|
+
return
|
|
328
|
+
self.app.confirm("停止全部远控服务?", self._do_stop_all)
|
|
329
|
+
elif key == "r":
|
|
330
|
+
self.app.trigger_async_refresh()
|
|
331
|
+
self.app.notify("刷新中…")
|
|
332
|
+
elif key == "?":
|
|
333
|
+
self._show_help()
|
|
334
|
+
|
|
335
|
+
def _do_stop_one(self, name: str) -> None:
|
|
336
|
+
"""Stop-one body, run only after the y/n confirm accepts."""
|
|
337
|
+
ok = rc.stop_one(name)
|
|
338
|
+
self.app.notify(f"已停止 {name}" if ok else "未在运行")
|
|
339
|
+
self.app.trigger_async_refresh()
|
|
340
|
+
|
|
341
|
+
def _do_stop_all(self) -> None:
|
|
342
|
+
"""Stop-all body, run only after the y/n confirm accepts."""
|
|
343
|
+
ok = rc.stop_all()
|
|
344
|
+
self.app.notify("已停止全部" if ok else "本来就没在跑")
|
|
345
|
+
self.app.trigger_async_refresh()
|
|
346
|
+
|
|
347
|
+
def _show_help(self) -> None:
|
|
348
|
+
self._help = True
|
|
349
|
+
lines = [
|
|
350
|
+
"远程控制操作(仅对「项目」行生效):",
|
|
351
|
+
" Enter 启动选中项目的远程控制服务",
|
|
352
|
+
" s 停止选中项目的远程控制服务(需确认)",
|
|
353
|
+
" a 切换「开机自启」:A 键一键启动时是否带上本项目",
|
|
354
|
+
" c 切换「自动远控」:claude 启动时自动开远程控制,手机即可接管",
|
|
355
|
+
"",
|
|
356
|
+
"批量操作:",
|
|
357
|
+
" A 启动所有「开机自启」项目",
|
|
358
|
+
" S 停止全部远程控制服务(需确认)",
|
|
359
|
+
" r 重新扫描刷新",
|
|
360
|
+
"",
|
|
361
|
+
"RC 服务 / 环境台账(只读):",
|
|
362
|
+
" 外部服务只展示,不接管、不重启。",
|
|
363
|
+
f" 孤儿环境无法本地注销:{_MANUAL_DELETE}(claude.ai/code)。",
|
|
364
|
+
" 孤儿清单不完整:csctl 未运行期间铸造的环境无法追踪。",
|
|
365
|
+
"",
|
|
366
|
+
"导航:",
|
|
367
|
+
" Tab 切换标签页",
|
|
368
|
+
" q 退出",
|
|
369
|
+
]
|
|
370
|
+
self.walker.clear()
|
|
371
|
+
for line in lines:
|
|
372
|
+
self.walker.append(urwid.AttrMap(urwid.Text(line), "dead"))
|
|
373
|
+
self.status.original_widget.set_text(" 按任意键返回")
|
|
374
|
+
self._update_footer()
|