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,145 @@
|
|
|
1
|
+
"""Row widgets + presentation helpers for the Sessions tab.
|
|
2
|
+
|
|
3
|
+
Split out of `views/sessions.py` so that file stays under the 600-line budget.
|
|
4
|
+
Holds the selectable `SessionRow` (with the D9 source badge + the 📱 remote-
|
|
5
|
+
control-exposure marker), the cleanup-submenu rows (`_ActionRow`, `_PreviewRow`),
|
|
6
|
+
and the column header constants. Rows never handle keys — `keypress` returns the key so the
|
|
7
|
+
view's single dispatcher sees it (see frontend/widget-patterns.md).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import urwid
|
|
15
|
+
|
|
16
|
+
from ..models import Session
|
|
17
|
+
|
|
18
|
+
# Transcript-derived hidden tags -> compact Chinese row marker.
|
|
19
|
+
_HIDDEN_MARKERS = {
|
|
20
|
+
"bridge": "桥接",
|
|
21
|
+
"sdk": "SDK",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Coarse registry `source` bucket -> short badge shown in the 来源 column.
|
|
25
|
+
_SOURCE_BADGES = {
|
|
26
|
+
"cli": "CLI",
|
|
27
|
+
"vscode": "IDE",
|
|
28
|
+
"sdk": "SDK",
|
|
29
|
+
"bg": "BG",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _hidden_marker(session: Session) -> str:
|
|
34
|
+
"""Compact `桥接 SDK` label from a session's transcript `hidden` tags."""
|
|
35
|
+
known = [label for key, label in _HIDDEN_MARKERS.items() if key in session.hidden]
|
|
36
|
+
unknown = sorted(key for key in session.hidden if key not in _HIDDEN_MARKERS)
|
|
37
|
+
return " ".join(known + unknown)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _source_badge(session: Session) -> str:
|
|
41
|
+
"""Short source badge (CLI / IDE / SDK / BG), or "" when unknown."""
|
|
42
|
+
return _SOURCE_BADGES.get(session.source, "")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _flags(session: Session) -> str:
|
|
46
|
+
"""Remote-control exposure marker for the 远控 column: 📱 when this session
|
|
47
|
+
exposes its own session-level remote control (phone / claude.ai/code can take
|
|
48
|
+
it over), else "". 📱 is Emoji_Presentation=Yes so its width is stable across
|
|
49
|
+
terminals (the old ⚙ agent glyph was the width-unstable one — text-default,
|
|
50
|
+
needs VS16 — and is the only thing P5 actually needed to drop). Agent-link is
|
|
51
|
+
deliberately NOT shown here: it is orthogonal to remote control and already
|
|
52
|
+
covered by the 来源 `BG` badge plus the dedicated 后台 tab."""
|
|
53
|
+
return "📱" if session.rc_exposed else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rel_time(mtime: float) -> str:
|
|
57
|
+
"""Human relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前; falls back to an
|
|
58
|
+
absolute %m-%d date past a week (and for a missing or future mtime)."""
|
|
59
|
+
if not mtime:
|
|
60
|
+
return "-"
|
|
61
|
+
delta = time.time() - mtime
|
|
62
|
+
if delta < 0:
|
|
63
|
+
return time.strftime("%m-%d %H:%M", time.localtime(mtime))
|
|
64
|
+
if delta < 60:
|
|
65
|
+
return "刚刚"
|
|
66
|
+
if delta < 3600:
|
|
67
|
+
return f"{int(delta // 60)} 分钟前"
|
|
68
|
+
if delta < 86400:
|
|
69
|
+
return f"{int(delta // 3600)} 小时前"
|
|
70
|
+
if delta < 7 * 86400:
|
|
71
|
+
return f"{int(delta // 86400)} 天前"
|
|
72
|
+
return time.strftime("%m-%d %H:%M", time.localtime(mtime))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SessionRow(urwid.WidgetWrap):
|
|
76
|
+
def __init__(self, session: Session) -> None:
|
|
77
|
+
self.session = session
|
|
78
|
+
mark = "●" if session.alive else "○"
|
|
79
|
+
cur = "▸" if session.current else " "
|
|
80
|
+
when = _rel_time(session.mtime)
|
|
81
|
+
hidden = _hidden_marker(session)
|
|
82
|
+
label = f"[{hidden}] {session.label}" if hidden else session.label
|
|
83
|
+
if len(label) > 80:
|
|
84
|
+
label = label[:79] + "…"
|
|
85
|
+
cwd = session.cwd.rstrip("/").rsplit("/", 1)[-1] if session.cwd else ""
|
|
86
|
+
|
|
87
|
+
cols = urwid.Columns([
|
|
88
|
+
(3, urwid.Text(f"{cur}{mark}")),
|
|
89
|
+
(5, urwid.Text(_source_badge(session))),
|
|
90
|
+
(5, urwid.Text(_flags(session))),
|
|
91
|
+
(12, urwid.Text(when)),
|
|
92
|
+
(5, urwid.Text(f"p{session.prompts}")),
|
|
93
|
+
("weight", 3, urwid.Text(label, wrap="clip")),
|
|
94
|
+
("weight", 1, urwid.Text(cwd, wrap="clip")),
|
|
95
|
+
], min_width=6)
|
|
96
|
+
|
|
97
|
+
attr = "alive" if session.alive else "dead"
|
|
98
|
+
mapped = urwid.AttrMap(cols, attr, focus_map={"alive": "selected", "dead": "selected", None: "selected"})
|
|
99
|
+
super().__init__(mapped)
|
|
100
|
+
|
|
101
|
+
def selectable(self) -> bool:
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
105
|
+
return key
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _ActionRow(urwid.WidgetWrap):
|
|
109
|
+
def __init__(self, action_key: str, label: str, count: int) -> None:
|
|
110
|
+
self.action_key = action_key
|
|
111
|
+
cols = urwid.Columns([
|
|
112
|
+
("weight", 1, urwid.Text(label)),
|
|
113
|
+
(8, urwid.Text(str(count), align="right")),
|
|
114
|
+
])
|
|
115
|
+
mapped = urwid.AttrMap(cols, "dead", focus_map={"dead": "selected", None: "selected"})
|
|
116
|
+
super().__init__(mapped)
|
|
117
|
+
|
|
118
|
+
def selectable(self) -> bool:
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
122
|
+
return key
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _PreviewRow(urwid.WidgetWrap):
|
|
126
|
+
def __init__(self, text: str) -> None:
|
|
127
|
+
mapped = urwid.AttrMap(urwid.Text(text), "dead", focus_map={"dead": "selected", None: "selected"})
|
|
128
|
+
super().__init__(mapped)
|
|
129
|
+
|
|
130
|
+
def selectable(self) -> bool:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
134
|
+
return key
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_SESSION_HEADER = urwid.Columns([
|
|
138
|
+
(3, urwid.Text("")),
|
|
139
|
+
(5, urwid.Text("来源")),
|
|
140
|
+
(5, urwid.Text("远控")),
|
|
141
|
+
(12, urwid.Text("时间")),
|
|
142
|
+
(5, urwid.Text("提问")),
|
|
143
|
+
("weight", 3, urwid.Text("标题")),
|
|
144
|
+
("weight", 1, urwid.Text("项目")),
|
|
145
|
+
], min_width=6)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Agents view — the 后台 (background agents) tab.
|
|
2
|
+
|
|
3
|
+
Lists `jobs/<short>/state.json` records (registry.AgentJob, enriched with host
|
|
4
|
+
liveness) and wires their lifecycle to `actions/agent_ops`: respawn, takeover
|
|
5
|
+
(via the existing resume path), read-only watch, settled-only remove, and
|
|
6
|
+
live-only stop. Satisfies the TabView Protocol structurally so `app.py` drives
|
|
7
|
+
it generically. All user-facing strings are Simplified Chinese; the orphan-risk
|
|
8
|
+
warning surfaced on `stop` is a capability red line (R4.5 / AC4).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import urwid
|
|
17
|
+
|
|
18
|
+
from ..actions import agent_ops
|
|
19
|
+
from ..actions.session_ops import would_take_over
|
|
20
|
+
from ..data import proc, registry
|
|
21
|
+
from ..models import AgentJob
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..data.snapshot import WorldSnapshot
|
|
25
|
+
|
|
26
|
+
from ..app import App
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# R10/D7: refusal shown when "current" can't be determined (no /proc) — the
|
|
30
|
+
# destructive ops are gated honestly BEFORE any confirm (mirrors SessionsView).
|
|
31
|
+
_DEGRADED = "liveness 降级:破坏性操作已禁用"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_AGENTS_HEADER = urwid.Columns([
|
|
35
|
+
(4, urwid.Text("")),
|
|
36
|
+
("weight", 2, urwid.Text("名称")),
|
|
37
|
+
(8, urwid.Text("状态")),
|
|
38
|
+
(8, urwid.Text("节奏")),
|
|
39
|
+
("weight", 2, urwid.Text("目录")),
|
|
40
|
+
("weight", 2, urwid.Text("环境后缀")),
|
|
41
|
+
], min_width=4)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AgentRow(urwid.WidgetWrap):
|
|
45
|
+
def __init__(self, job: AgentJob) -> None:
|
|
46
|
+
self.job = job
|
|
47
|
+
mark = "●" if job.host_alive else "○"
|
|
48
|
+
cwd = job.cwd.rstrip("/").rsplit("/", 1)[-1] if job.cwd else ""
|
|
49
|
+
cols = urwid.Columns([
|
|
50
|
+
(4, urwid.Text(mark, align="center")),
|
|
51
|
+
("weight", 2, urwid.Text(job.name or job.short, wrap="clip")),
|
|
52
|
+
(8, urwid.Text(job.state or "-", wrap="clip")),
|
|
53
|
+
(8, urwid.Text(job.tempo or "-", wrap="clip")),
|
|
54
|
+
("weight", 2, urwid.Text(cwd, wrap="clip")),
|
|
55
|
+
("weight", 2, urwid.Text(job.env_suffix or "-", wrap="clip")),
|
|
56
|
+
], min_width=4)
|
|
57
|
+
attr = "alive" if job.host_alive else "dead"
|
|
58
|
+
mapped = urwid.AttrMap(cols, attr, focus_map={"alive": "selected", "dead": "selected", None: "selected"})
|
|
59
|
+
super().__init__(mapped)
|
|
60
|
+
|
|
61
|
+
def selectable(self) -> bool:
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
65
|
+
return key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _TextRow(urwid.WidgetWrap):
|
|
69
|
+
"""Read-only line used in the watch overlay."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, text: str) -> None:
|
|
72
|
+
mapped = urwid.AttrMap(urwid.Text(text), "dead", focus_map={"dead": "selected", None: "selected"})
|
|
73
|
+
super().__init__(mapped)
|
|
74
|
+
|
|
75
|
+
def selectable(self) -> bool:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def keypress(self, size: tuple, key: str) -> str | None:
|
|
79
|
+
return key
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AgentsView:
|
|
83
|
+
# mode: "list" | "help" | "watch"
|
|
84
|
+
def __init__(self, app: App) -> None:
|
|
85
|
+
self.app = app
|
|
86
|
+
self._jobs: list[AgentJob] = []
|
|
87
|
+
self._pending: list[AgentJob] | None = None
|
|
88
|
+
self._loaded = False
|
|
89
|
+
self._mode = "list"
|
|
90
|
+
|
|
91
|
+
self.status = urwid.AttrMap(urwid.Text(" 扫描中…"), "status")
|
|
92
|
+
col_header = urwid.AttrMap(_AGENTS_HEADER, "col_header")
|
|
93
|
+
self.walker = urwid.SimpleFocusListWalker([])
|
|
94
|
+
self.listbox = urwid.ListBox(self.walker)
|
|
95
|
+
self._list_body = urwid.AttrMap(self.listbox, {None: "body"})
|
|
96
|
+
self._body = urwid.WidgetPlaceholder(self._list_body)
|
|
97
|
+
self.widget = urwid.Frame(self._body, header=col_header, footer=self.status)
|
|
98
|
+
|
|
99
|
+
# --- TabView contract ---
|
|
100
|
+
|
|
101
|
+
def keyhints(self) -> str:
|
|
102
|
+
if self._mode in ("help", "watch"):
|
|
103
|
+
return "按任意键返回"
|
|
104
|
+
return f"{agent_ops.KEYHINTS} · ? 帮助"
|
|
105
|
+
|
|
106
|
+
def _enrich(self, jobs: list[AgentJob]) -> list[AgentJob]:
|
|
107
|
+
"""Fill host liveness for the self-fetch path (snapshot already enriched).
|
|
108
|
+
|
|
109
|
+
Returns fresh copies via `dataclasses.replace` (like `snapshot._enrich_jobs`)
|
|
110
|
+
so the registry's ~5s-TTL cached AgentJob objects are never mutated.
|
|
111
|
+
"""
|
|
112
|
+
out: list[AgentJob] = []
|
|
113
|
+
for job in jobs:
|
|
114
|
+
pid, alive = agent_ops.job_host(job)
|
|
115
|
+
out.append(replace(job, host_pid=pid, host_alive=alive))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
def load(self) -> None:
|
|
119
|
+
self._jobs = self._enrich(registry.read_agent_jobs())
|
|
120
|
+
self._loaded = True
|
|
121
|
+
self._rebuild()
|
|
122
|
+
|
|
123
|
+
def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None:
|
|
124
|
+
"""Worker-thread data fetch. Only sets pending fields — no widgets."""
|
|
125
|
+
if snapshot is not None:
|
|
126
|
+
self._pending = snapshot.agent_jobs
|
|
127
|
+
else:
|
|
128
|
+
self._pending = self._enrich(registry.read_agent_jobs())
|
|
129
|
+
|
|
130
|
+
def apply_data(self) -> None:
|
|
131
|
+
if self._pending is not None:
|
|
132
|
+
self._jobs = self._pending
|
|
133
|
+
self._pending = None
|
|
134
|
+
self._loaded = True
|
|
135
|
+
if self._mode == "list":
|
|
136
|
+
self._rebuild()
|
|
137
|
+
|
|
138
|
+
# --- rendering ---
|
|
139
|
+
|
|
140
|
+
def _rebuild(self) -> None:
|
|
141
|
+
focus_pos = self.walker.get_focus()[1] if self.walker else 0
|
|
142
|
+
self.walker.clear()
|
|
143
|
+
for job in self._jobs:
|
|
144
|
+
self.walker.append(AgentRow(job))
|
|
145
|
+
if not self._jobs:
|
|
146
|
+
self.walker.append(urwid.AttrMap(urwid.Text(" 暂无后台 agent"), "dead"))
|
|
147
|
+
if self.walker and focus_pos is not None:
|
|
148
|
+
self.walker.set_focus(min(focus_pos, len(self.walker) - 1))
|
|
149
|
+
alive_n = sum(1 for j in self._jobs if j.host_alive)
|
|
150
|
+
self.status.original_widget.set_text(
|
|
151
|
+
f" 共 {len(self._jobs)} 个后台 agent · 运行 {alive_n}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _selected(self) -> AgentJob | None:
|
|
155
|
+
if not self.walker:
|
|
156
|
+
return None
|
|
157
|
+
widget = self.walker.get_focus()[0]
|
|
158
|
+
if isinstance(widget, AgentRow):
|
|
159
|
+
return widget.job
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _update_footer(self) -> None:
|
|
163
|
+
if self.app.views[self.app._active] is not self:
|
|
164
|
+
return
|
|
165
|
+
self.app.set_hints(self.keyhints())
|
|
166
|
+
|
|
167
|
+
def _show_overlay(self, title: str, rows: list, height: int | None = None) -> None:
|
|
168
|
+
walker = urwid.SimpleFocusListWalker(rows)
|
|
169
|
+
listbox = urwid.ListBox(walker)
|
|
170
|
+
header = urwid.AttrMap(urwid.Text(f" {title}", align="center"), "col_header")
|
|
171
|
+
box = urwid.LineBox(urwid.Frame(listbox, header=header))
|
|
172
|
+
h = height or min(len(rows) + 4, 30)
|
|
173
|
+
overlay = urwid.Overlay(
|
|
174
|
+
box, self._list_body,
|
|
175
|
+
align="center", width=("relative", 80),
|
|
176
|
+
valign="middle", height=h,
|
|
177
|
+
)
|
|
178
|
+
self._body.original_widget = overlay
|
|
179
|
+
|
|
180
|
+
def _exit_overlay(self) -> None:
|
|
181
|
+
self._mode = "list"
|
|
182
|
+
self._body.original_widget = self._list_body
|
|
183
|
+
self._rebuild()
|
|
184
|
+
self._update_footer()
|
|
185
|
+
|
|
186
|
+
# --- key dispatch ---
|
|
187
|
+
|
|
188
|
+
def handle_key(self, key: str) -> None:
|
|
189
|
+
if self._mode in ("help", "watch"):
|
|
190
|
+
self._exit_overlay()
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
job = self._selected()
|
|
194
|
+
|
|
195
|
+
if key == "r":
|
|
196
|
+
# Unified verb table: `r` is refresh on EVERY tab (respawn moved to R).
|
|
197
|
+
self.app.trigger_async_refresh()
|
|
198
|
+
self.app.notify("刷新中…")
|
|
199
|
+
elif key == "R" and job:
|
|
200
|
+
cmd = agent_ops.respawn(job)
|
|
201
|
+
self.app.notify(f"已重启:{cmd}")
|
|
202
|
+
self.app.trigger_async_refresh()
|
|
203
|
+
elif key in ("enter", "o") and job:
|
|
204
|
+
self._takeover(job)
|
|
205
|
+
elif key == "w" and job:
|
|
206
|
+
self._watch(job)
|
|
207
|
+
elif key == "d" and job:
|
|
208
|
+
self._remove(job)
|
|
209
|
+
elif key == "s" and job:
|
|
210
|
+
self._stop(job)
|
|
211
|
+
elif key == "?":
|
|
212
|
+
self._show_help()
|
|
213
|
+
|
|
214
|
+
def _takeover(self, job: AgentJob) -> None:
|
|
215
|
+
s = agent_ops.resume_takeover(job)
|
|
216
|
+
if s.current:
|
|
217
|
+
self.app.notify("不能接回当前会话")
|
|
218
|
+
return
|
|
219
|
+
# B1: takeover of a RUNNING worker kills its host pid (should_kill) — same
|
|
220
|
+
# as Sessions Enter-live. Confirm first; a dead worker resumes directly.
|
|
221
|
+
if would_take_over(s):
|
|
222
|
+
self.app.confirm(
|
|
223
|
+
f"接回后台 agent「{(job.name or job.short)[:30]}」?将先终止原进程。",
|
|
224
|
+
lambda: self.app.exit_with_resume(s, fork=False),
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
self.app.exit_with_resume(s, fork=False)
|
|
228
|
+
|
|
229
|
+
def _watch(self, job: AgentJob) -> None:
|
|
230
|
+
path = agent_ops.watch(job)
|
|
231
|
+
if not path:
|
|
232
|
+
self.app.notify("无 timeline 可查看")
|
|
233
|
+
return
|
|
234
|
+
lines: list[str] = []
|
|
235
|
+
try:
|
|
236
|
+
with open(path, errors="ignore") as fh:
|
|
237
|
+
lines = fh.read().splitlines()[-200:]
|
|
238
|
+
except Exception:
|
|
239
|
+
self.app.notify("读取 timeline 失败")
|
|
240
|
+
return
|
|
241
|
+
rows = [_TextRow(line) for line in lines] or [_TextRow("(空)")]
|
|
242
|
+
self._mode = "watch"
|
|
243
|
+
self._show_overlay(f"timeline(只读)· {job.name or job.short}", rows)
|
|
244
|
+
self._update_footer()
|
|
245
|
+
|
|
246
|
+
def _remove(self, job: AgentJob) -> None:
|
|
247
|
+
if job.host_alive:
|
|
248
|
+
self.app.notify("运行中的后台 agent 不能删除,先停止")
|
|
249
|
+
return
|
|
250
|
+
if not proc.current_determinable():
|
|
251
|
+
self.app.notify(_DEGRADED)
|
|
252
|
+
return
|
|
253
|
+
ok = agent_ops.remove_job(job)
|
|
254
|
+
self.app.notify("已删除" if ok else "删除失败")
|
|
255
|
+
self.app.trigger_async_refresh()
|
|
256
|
+
|
|
257
|
+
def _stop(self, job: AgentJob) -> None:
|
|
258
|
+
# Degrade gate FIRST (R2): off /proc we can't prove this isn't csctl's own
|
|
259
|
+
# session — refuse honestly before confirming.
|
|
260
|
+
if not proc.current_determinable():
|
|
261
|
+
self.app.notify(_DEGRADED)
|
|
262
|
+
return
|
|
263
|
+
if not job.host_alive:
|
|
264
|
+
self.app.notify("后台 agent 未在运行")
|
|
265
|
+
return
|
|
266
|
+
self.app.confirm(
|
|
267
|
+
f"停止后台 agent「{(job.name or job.short)[:30]}」?将终止其进程。",
|
|
268
|
+
lambda: self._do_stop(job),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _do_stop(self, job: AgentJob) -> None:
|
|
272
|
+
"""Stop body, run only after the y/n confirm accepts.
|
|
273
|
+
|
|
274
|
+
Reached only when "current" is determinable, so a False from `stop_job`
|
|
275
|
+
means no joined host pid (an unstoppable orphan) — surfaced honestly,
|
|
276
|
+
separate from the degrade refusal above (R2 split).
|
|
277
|
+
"""
|
|
278
|
+
ok = agent_ops.stop_job(job)
|
|
279
|
+
if ok:
|
|
280
|
+
self.app.notify("已发送停止信号(可能残留孤儿进程,请手动确认)")
|
|
281
|
+
else:
|
|
282
|
+
self.app.notify("找不到该后台 agent 的进程,无法停止")
|
|
283
|
+
self.app.trigger_async_refresh()
|
|
284
|
+
|
|
285
|
+
def _show_help(self) -> None:
|
|
286
|
+
rows = [_TextRow(line) for line in agent_ops.HELP.splitlines()]
|
|
287
|
+
rows += [
|
|
288
|
+
_TextRow(""),
|
|
289
|
+
_TextRow("导航:Tab 切换标签 · q 退出"),
|
|
290
|
+
]
|
|
291
|
+
self._mode = "help"
|
|
292
|
+
self._show_overlay("后台 agent 帮助", rows)
|
|
293
|
+
self._update_footer()
|