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
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Background-agent lifecycle actions (R4 / Phase 6).
|
|
2
|
+
|
|
3
|
+
The persistent truth for a background agent lives in `jobs/<short>/state.json`
|
|
4
|
+
(registry.read_agent_jobs → AgentJob); it carries NO pid. So a live worker's host
|
|
5
|
+
pid is resolved by JOINing the job's sid back to `sessions/<pid>.json`
|
|
6
|
+
(`job_host`) — a live worker with no sessions file is therefore unstoppable, a
|
|
7
|
+
documented orphan risk surfaced in `HELP`.
|
|
8
|
+
|
|
9
|
+
Capability red lines honoured here:
|
|
10
|
+
- respawn/takeover never replace the csctl process (respawn spawns a tmux
|
|
11
|
+
window; takeover hands a Session to the existing `do_resume` path run AFTER
|
|
12
|
+
the UI loop exits).
|
|
13
|
+
- stop only signals a confirmed-live joined host pid; killing a
|
|
14
|
+
`--remote-control`/bg worker does not always fully reap it (orphan risk).
|
|
15
|
+
- destructive ops (remove/stop) refuse when "current" can't be determined
|
|
16
|
+
(no `/proc`, R10) so they never blind-hit csctl's own session.
|
|
17
|
+
|
|
18
|
+
This is an action module: internals are English, but the user-facing label/help
|
|
19
|
+
constants the (Phase 7) background view reads are Simplified Chinese.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import signal
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import replace
|
|
29
|
+
|
|
30
|
+
from ..config import cfg
|
|
31
|
+
from ..data import cleanup, liveness, proc, rc, registry
|
|
32
|
+
from ..models import AgentJob, Session
|
|
33
|
+
|
|
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
|
+
# --- host-pid join (shared by stop_job, remove_job, and the view) -------------
|
|
57
|
+
|
|
58
|
+
def job_host(job: AgentJob) -> tuple[int | None, bool]:
|
|
59
|
+
"""Resolve a background job's host pid + liveness — `(pid, alive)`.
|
|
60
|
+
|
|
61
|
+
`state.json` has no pid, so the worker's pid is JOINed from
|
|
62
|
+
`sessions/<pid>.json` on `job.sid` (a bg session proc; `kind` is typically
|
|
63
|
+
"bg"). Prefers a `/proc`-confirmed live match (so `alive=True` is trustworthy
|
|
64
|
+
and defeats pid reuse via `procStart`); falls back to the first sid match
|
|
65
|
+
with `alive=False`. Returns `(None, False)` when no sessions file exists for
|
|
66
|
+
the sid — that live worker is unstoppable (documented orphan risk).
|
|
67
|
+
|
|
68
|
+
Injects `/proc` liveness onto the registry rows, then defers to the single
|
|
69
|
+
pure join `registry.host_pid_for_sid` (shared with `snapshot._enrich_jobs`).
|
|
70
|
+
"""
|
|
71
|
+
procs = [
|
|
72
|
+
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
73
|
+
for sp in registry.read_session_procs()
|
|
74
|
+
]
|
|
75
|
+
return registry.host_pid_for_sid(job.sid, procs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- respawn ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def respawn_cmd(job: AgentJob) -> str:
|
|
81
|
+
"""The exact relaunch command: `claude --resume <resume_sid> <flags> --bg`.
|
|
82
|
+
|
|
83
|
+
Pure string build via `shlex.join` (split from `respawn` so it can be copied
|
|
84
|
+
to the clipboard / asserted in tests). `respawn_flags` are reused verbatim
|
|
85
|
+
from the recorded job state.
|
|
86
|
+
"""
|
|
87
|
+
args = ["claude", "--resume", job.resume_sid, *job.respawn_flags, "--bg"]
|
|
88
|
+
return shlex.join(args)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _job_window(job: AgentJob) -> str:
|
|
92
|
+
"""tmux window name for a respawned agent (name or short, suffixed)."""
|
|
93
|
+
base = (job.name or "bg").strip() or "bg"
|
|
94
|
+
return f"{base}-{job.short[:8]}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def respawn(job: AgentJob) -> str:
|
|
98
|
+
"""Relaunch a background agent in tmux; returns the exact command string.
|
|
99
|
+
|
|
100
|
+
Runs `respawn_cmd(job)` in the shared tmux session (`cfg.tmux_session`) so it
|
|
101
|
+
outlives the terminal — it does NOT os.exec/replace the csctl process. The
|
|
102
|
+
returned string also feeds the clipboard `y`-style key.
|
|
103
|
+
"""
|
|
104
|
+
cmd = respawn_cmd(job)
|
|
105
|
+
rc.run_in_tmux(cfg.tmux_session, _job_window(job), cmd)
|
|
106
|
+
return cmd
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- remove (settled agents only) ---------------------------------------------
|
|
110
|
+
|
|
111
|
+
def remove_job(job: AgentJob) -> bool:
|
|
112
|
+
"""Remove a SETTLED background agent: `jobs/<short>/` + its sid artifacts.
|
|
113
|
+
|
|
114
|
+
Returns True iff the job dir was removed. Refuses (False) for a LIVE worker
|
|
115
|
+
(`job_host` reports alive) and when "current" can't be determined (no
|
|
116
|
+
`/proc`, R10) — destructive, must not run blind.
|
|
117
|
+
"""
|
|
118
|
+
if not proc.current_determinable():
|
|
119
|
+
return False
|
|
120
|
+
_, alive = job_host(job)
|
|
121
|
+
if alive:
|
|
122
|
+
return False
|
|
123
|
+
job_dir = os.path.join(str(cfg.jobs_dir), job.short)
|
|
124
|
+
removed = cleanup._remove_path(job_dir)
|
|
125
|
+
# Reuse cleanup's artifact-path helper / remover: it returns the sid-keyed
|
|
126
|
+
# dirs (session-env/file-history/tasks/uploads) plus jobs/<sid[:8]> (which
|
|
127
|
+
# usually equals job_dir — a second remove is a harmless no-op), so the job's
|
|
128
|
+
# session leaves no orphan artifacts behind.
|
|
129
|
+
for path in cleanup._session_artifact_paths(job.sid):
|
|
130
|
+
cleanup._remove_path(path)
|
|
131
|
+
return removed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- watch (read-only) --------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def watch(job: AgentJob) -> str | None:
|
|
137
|
+
"""Path to the job's read-only `jobs/<short>/timeline.jsonl`, or None.
|
|
138
|
+
|
|
139
|
+
Pure lookup, no mutation — returns the path only when the file exists so the
|
|
140
|
+
view can fall back gracefully (R4.4 read-only watch).
|
|
141
|
+
"""
|
|
142
|
+
path = os.path.join(str(cfg.jobs_dir), job.short, "timeline.jsonl")
|
|
143
|
+
return path if os.path.isfile(path) else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- resume takeover (reuses the existing foreground resume path) -------------
|
|
147
|
+
|
|
148
|
+
def resume_takeover(job: AgentJob) -> Session:
|
|
149
|
+
"""Adapt a background job into a `Session` for the EXISTING resume path.
|
|
150
|
+
|
|
151
|
+
Bringing a bg session to the foreground is just a resume of its
|
|
152
|
+
`resume_sid`, so this returns a Session the view feeds to the SAME
|
|
153
|
+
`app.exit_with_resume` → `do_resume` pipeline used for foreground sessions —
|
|
154
|
+
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
155
|
+
`pid`/`alive` come from the host join so a live worker is killed first
|
|
156
|
+
(resume = takeover); `current` is computed so the launching session stays
|
|
157
|
+
protected. Does NOT itself replace the csctl process.
|
|
158
|
+
"""
|
|
159
|
+
pid, alive = job_host(job)
|
|
160
|
+
current = bool(pid) and pid in proc.ancestor_pids()
|
|
161
|
+
return Session(
|
|
162
|
+
sid=job.resume_sid,
|
|
163
|
+
cwd=job.cwd,
|
|
164
|
+
label=job.name or job.short,
|
|
165
|
+
mtime=0.0,
|
|
166
|
+
prompts=0,
|
|
167
|
+
pid=pid,
|
|
168
|
+
alive=alive,
|
|
169
|
+
current=current,
|
|
170
|
+
source="bg",
|
|
171
|
+
agent_short=job.short,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- stop (live workers only) -------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def stop_job(job: AgentJob) -> bool:
|
|
178
|
+
"""Stop a LIVE background worker via its joined host pid. True iff signalled.
|
|
179
|
+
|
|
180
|
+
The host pid is JOINed from `sessions/<pid>.json` (`job_host`); only a
|
|
181
|
+
confirmed-live pid is killed — a worker with no sessions file is unstoppable
|
|
182
|
+
(no-op False, orphan risk). Refuses when "current" can't be determined (no
|
|
183
|
+
`/proc`, R10). Owns the liveness-cache invalidation (like terminate). Killing
|
|
184
|
+
does not always fully reap a `--remote-control`/bg worker (orphan risk, see
|
|
185
|
+
`HELP`).
|
|
186
|
+
"""
|
|
187
|
+
if not proc.current_determinable():
|
|
188
|
+
return False
|
|
189
|
+
pid, alive = job_host(job)
|
|
190
|
+
if not alive or not pid:
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
os.kill(pid, signal.SIGTERM)
|
|
194
|
+
except ProcessLookupError:
|
|
195
|
+
liveness.invalidate_cache() # already gone — liveness changed
|
|
196
|
+
return True
|
|
197
|
+
except Exception:
|
|
198
|
+
return False
|
|
199
|
+
time.sleep(1)
|
|
200
|
+
liveness.invalidate_cache()
|
|
201
|
+
return True
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Session operations: resume, terminate, delete, clipboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .. import clipboard
|
|
11
|
+
from ..config import cfg
|
|
12
|
+
from ..data import proc, rc
|
|
13
|
+
from ..data.liveness import invalidate_cache
|
|
14
|
+
from ..models import Session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def terminate_session(s: Session) -> bool:
|
|
18
|
+
"""Send SIGTERM and own the liveness-cache invalidation.
|
|
19
|
+
|
|
20
|
+
Terminating is the one session op that changes `claude agents` liveness,
|
|
21
|
+
so it invalidates the alive_map cache itself — callers no longer have to
|
|
22
|
+
remember to. (delete/cleanup only touch already-dead sessions, so they
|
|
23
|
+
don't.)
|
|
24
|
+
|
|
25
|
+
R10: refuses (returns False) when "current" can't be determined (no
|
|
26
|
+
`/proc`) — we can't prove `s` is not the launching session, so a SIGTERM
|
|
27
|
+
here could hit csctl's own session.
|
|
28
|
+
"""
|
|
29
|
+
if not proc.current_determinable():
|
|
30
|
+
return False
|
|
31
|
+
if not s.pid:
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
35
|
+
except ProcessLookupError:
|
|
36
|
+
invalidate_cache() # already gone — liveness changed
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
time.sleep(1)
|
|
41
|
+
invalidate_cache()
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resume_plan(s: Session, fork: bool = False) -> tuple[str, list[str], bool]:
|
|
46
|
+
"""Shared resume recipe: the cwd to enter, the claude argv, and whether
|
|
47
|
+
to kill the old session first.
|
|
48
|
+
|
|
49
|
+
Returns (cwd, args, should_kill). Unified kill semantics: a fork is a copy
|
|
50
|
+
and leaves the original running, while a plain resume takes the session
|
|
51
|
+
over — so we kill only when it is alive, not the current session, and we
|
|
52
|
+
are NOT forking. `resume_cmd` and `do_resume` both obey this single
|
|
53
|
+
decision; they must not re-derive it.
|
|
54
|
+
"""
|
|
55
|
+
args = ["claude", "--resume", s.sid]
|
|
56
|
+
if fork:
|
|
57
|
+
args.append("--fork-session")
|
|
58
|
+
should_kill = s.alive and not s.current and not fork
|
|
59
|
+
return s.cwd, args, should_kill
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def would_take_over(s: Session, fork: bool = False) -> bool:
|
|
63
|
+
"""Whether resuming/relaunching `s` would first kill a live process (takeover).
|
|
64
|
+
|
|
65
|
+
The single source of the "needs confirmation" decision for the UI: it reads
|
|
66
|
+
`_resume_plan`'s `should_kill` so views never re-derive `s.alive and not
|
|
67
|
+
s.current` themselves (CLAUDE.md: should_kill is single-point — re-derivation
|
|
68
|
+
was the old divergence). `do_resume`/`relaunch_in_tmux` and the confirm gate
|
|
69
|
+
thus agree by construction.
|
|
70
|
+
"""
|
|
71
|
+
return _resume_plan(s, fork)[2]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resume_cmd(s: Session, fork: bool = False) -> str:
|
|
75
|
+
cwd, args, should_kill = _resume_plan(s, fork)
|
|
76
|
+
parts: list[str] = []
|
|
77
|
+
if should_kill and s.pid: # never emit a bare `kill None` (L7)
|
|
78
|
+
parts.append(f"kill {s.pid} && sleep 1")
|
|
79
|
+
if cwd:
|
|
80
|
+
parts.append(f"cd {shlex.quote(cwd)}")
|
|
81
|
+
parts.append(shlex.join(args))
|
|
82
|
+
return " && ".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def do_resume(s: Session, fork: bool = False) -> None:
|
|
86
|
+
"""chdir + (kill if needed) + exec claude. Does not return on success.
|
|
87
|
+
|
|
88
|
+
R10: when a takeover kill is required but "current" can't be determined (no
|
|
89
|
+
`/proc`), refuse — print a message and return WITHOUT killing or exec'ing, so
|
|
90
|
+
we never SIGTERM the launching session (every pid looks dead off `/proc`).
|
|
91
|
+
"""
|
|
92
|
+
cwd, args, should_kill = _resume_plan(s, fork)
|
|
93
|
+
if should_kill:
|
|
94
|
+
if not proc.current_determinable():
|
|
95
|
+
print(
|
|
96
|
+
"Refused: '/proc' unavailable — cannot determine the current "
|
|
97
|
+
"session, so the old process can't be safely killed (R10)."
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
time.sleep(1)
|
|
105
|
+
if cwd and os.path.isdir(cwd):
|
|
106
|
+
os.chdir(cwd)
|
|
107
|
+
os.execvp("claude", args)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rc_name(s: Session) -> str:
|
|
111
|
+
"""Remote-control label (shown in claude.ai/code) for a relaunched session."""
|
|
112
|
+
base = s.cwd.rstrip("/").rsplit("/", 1)[-1] if s.cwd else ""
|
|
113
|
+
return f"{base or 'session'}-{s.sid[:8]}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
|
|
117
|
+
"""Shell command that resumes the session under remote control."""
|
|
118
|
+
cwd, args, _ = _resume_plan(s, fork)
|
|
119
|
+
args = args + ["--remote-control", _rc_name(s)]
|
|
120
|
+
line = shlex.join(args)
|
|
121
|
+
return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
|
|
125
|
+
"""Relaunch a session as `claude --resume … --remote-control …` inside a
|
|
126
|
+
tmux window, so it outlives the terminal and is remotely controllable.
|
|
127
|
+
|
|
128
|
+
A live, non-current session is taken over (its old pid is killed first and
|
|
129
|
+
the liveness cache invalidated, like terminate); a fork leaves the original
|
|
130
|
+
running. csctl is NOT replaced — it just spawns the tmux window.
|
|
131
|
+
|
|
132
|
+
R10: when a takeover kill is required but "current" can't be determined (no
|
|
133
|
+
`/proc`), refuse (return False, do not kill or relaunch) — we can't prove `s`
|
|
134
|
+
is not the launching session.
|
|
135
|
+
"""
|
|
136
|
+
_, _, should_kill = _resume_plan(s, fork)
|
|
137
|
+
if should_kill and s.pid:
|
|
138
|
+
if not proc.current_determinable():
|
|
139
|
+
return False
|
|
140
|
+
try:
|
|
141
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
time.sleep(1)
|
|
145
|
+
invalidate_cache()
|
|
146
|
+
return rc.run_in_tmux(cfg.tmux_session, _rc_name(s), tmux_resume_cmd(s, fork))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_clipboard(text: str) -> bool:
|
|
150
|
+
return clipboard.copy(text)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""CCM — Claude Code Manager urwid App."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import curses
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
import urwid
|
|
12
|
+
|
|
13
|
+
from .data import proc
|
|
14
|
+
from .data.snapshot import WorldSnapshot, build_world_snapshot
|
|
15
|
+
from .views.agents import AgentsView
|
|
16
|
+
from .views.rc import RCView
|
|
17
|
+
from .views.sessions import SessionsView
|
|
18
|
+
|
|
19
|
+
# D7/R10: shown across all tabs when `/proc` is unavailable (e.g. macOS), where
|
|
20
|
+
# the "current" session can't be determined and destructive ops are refused.
|
|
21
|
+
_DEGRADED_BANNER = "⚠ liveness 降级(无 /proc):terminate/delete/cleanup 已受限"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class TabView(Protocol):
|
|
26
|
+
"""The contract App uses to drive each tab generically.
|
|
27
|
+
|
|
28
|
+
A tab satisfies this structurally — App never special-cases a concrete
|
|
29
|
+
view. `fetch_pending(snapshot)` runs on the worker thread and must not touch
|
|
30
|
+
widgets; `apply_data()` runs on the main loop and swaps `_pending` into the
|
|
31
|
+
walker. The `snapshot` is the shared per-cycle world (R11/D8); it is OPTIONAL
|
|
32
|
+
— a view called with `None` self-fetches (back-compat / tests). Adding a tab
|
|
33
|
+
means honoring every member below.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
widget: urwid.Widget
|
|
37
|
+
_loaded: bool
|
|
38
|
+
|
|
39
|
+
def load(self) -> None: ...
|
|
40
|
+
def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None: ...
|
|
41
|
+
def apply_data(self) -> None: ...
|
|
42
|
+
def keyhints(self) -> str: ...
|
|
43
|
+
def handle_key(self, key: str) -> None: ...
|
|
44
|
+
|
|
45
|
+
# 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
|
|
46
|
+
PALETTE = [
|
|
47
|
+
("header", "white,bold", "black", "bold", "#fff,bold", "#111"),
|
|
48
|
+
("footer", "light gray", "black", None, "#999", "#111"),
|
|
49
|
+
("tab_on", "white,bold", "dark cyan", "bold,standout", "#fff,bold", "#068"),
|
|
50
|
+
("tab_off", "dark cyan", "black", None, "#688", "#111"),
|
|
51
|
+
("alive", "light green", "black", None, "#6d6", "#111"),
|
|
52
|
+
("dead", "light gray", "black", None, "#ccc", "#111"),
|
|
53
|
+
("selected", "white,bold", "dark cyan", "standout", "#fff,bold", "#068"),
|
|
54
|
+
("notify", "yellow,bold", "black", "bold", "#ff0,bold", "#111"),
|
|
55
|
+
("status", "light gray", "black", None, "#aaa", "#111"),
|
|
56
|
+
("rc_running", "light green", "black", None, "#6d6", "#111"),
|
|
57
|
+
("rc_stopped", "light gray", "black", None, "#ccc", "#111"),
|
|
58
|
+
("body", "light gray", "black", None, "#ccc", "#111"),
|
|
59
|
+
("col_header", "dark cyan", "black", None, "#8aa", "#181818"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
TAB_NAMES = ["会话", "后台", "远程控制"]
|
|
63
|
+
|
|
64
|
+
# D1: all three tabs share ONE footer prefix — the universal verbs (Tab/q/r) live
|
|
65
|
+
# here exactly once so `r 刷新` shows identically on every tab. View-specific keys
|
|
66
|
+
# come from each view's `keyhints()` and are appended via `App.set_hints`.
|
|
67
|
+
FOOTER_PREFIX = " Tab 切换 · q 退出 · r 刷新 · "
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _make_screen() -> urwid.raw_display.Screen:
|
|
71
|
+
screen = urwid.raw_display.Screen()
|
|
72
|
+
try:
|
|
73
|
+
curses.setupterm()
|
|
74
|
+
term_colors = curses.tigetnum("colors")
|
|
75
|
+
if term_colors >= 256:
|
|
76
|
+
screen.set_terminal_properties(colors=256)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
screen.register_palette(PALETTE)
|
|
80
|
+
return screen
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class App:
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.result: tuple | None = None
|
|
86
|
+
self._exiting = False
|
|
87
|
+
self._alarm_handle: object | None = None
|
|
88
|
+
self._pipe_fd: int | None = None
|
|
89
|
+
self._refreshing = False
|
|
90
|
+
# When set, a y/n confirm modal is up: `_input` routes y/n/esc here and
|
|
91
|
+
# swallows every other key (App-level so all tabs share it — D8 view files
|
|
92
|
+
# stay small). `_confirm_base` is the widget restored on close.
|
|
93
|
+
self._confirm_yes: Callable[[], None] | None = None
|
|
94
|
+
self._confirm_base: urwid.Widget | None = None
|
|
95
|
+
|
|
96
|
+
self.views: list[TabView] = [SessionsView(self), AgentsView(self), RCView(self)]
|
|
97
|
+
self._active = 0
|
|
98
|
+
|
|
99
|
+
self.body = urwid.WidgetPlaceholder(self.views[0].widget)
|
|
100
|
+
self._tab_texts: list[urwid.Text] = []
|
|
101
|
+
tab_bar = self._build_tab_bar()
|
|
102
|
+
title = urwid.AttrMap(urwid.Text("Claude Code 会话管理器", align="center"), "header")
|
|
103
|
+
# Title at 0 and tab_bar at 1 are positional (see `_update_tab_bar`); the
|
|
104
|
+
# degraded banner, if any, is appended LAST so those indices are stable.
|
|
105
|
+
header_rows: list[urwid.Widget] = [title, tab_bar]
|
|
106
|
+
if not proc.has_proc():
|
|
107
|
+
header_rows.append(urwid.AttrMap(urwid.Text(f" {_DEGRADED_BANNER}"), "notify"))
|
|
108
|
+
self.header = urwid.Pile(header_rows)
|
|
109
|
+
|
|
110
|
+
self.footer_text = urwid.Text(FOOTER_PREFIX)
|
|
111
|
+
self.footer = urwid.AttrMap(self.footer_text, "footer")
|
|
112
|
+
|
|
113
|
+
self.frame = urwid.Frame(self.body, header=self.header, footer=self.footer)
|
|
114
|
+
|
|
115
|
+
self._screen = _make_screen()
|
|
116
|
+
self.loop = urwid.MainLoop(
|
|
117
|
+
self.frame,
|
|
118
|
+
screen=self._screen,
|
|
119
|
+
unhandled_input=self._input,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _build_tab_bar(self) -> urwid.Columns:
|
|
123
|
+
self._tab_texts = []
|
|
124
|
+
cols = []
|
|
125
|
+
for i, name in enumerate(TAB_NAMES):
|
|
126
|
+
txt = urwid.Text(f" {name} ", align="center")
|
|
127
|
+
self._tab_texts.append(txt)
|
|
128
|
+
attr = "tab_on" if i == self._active else "tab_off"
|
|
129
|
+
cols.append(urwid.AttrMap(txt, attr))
|
|
130
|
+
return urwid.Columns(cols)
|
|
131
|
+
|
|
132
|
+
def _update_tab_bar(self) -> None:
|
|
133
|
+
tab_bar = self._build_tab_bar()
|
|
134
|
+
self.header.contents[1] = (tab_bar, self.header.options())
|
|
135
|
+
|
|
136
|
+
def _switch_tab(self) -> None:
|
|
137
|
+
self._active = (self._active + 1) % len(self.views)
|
|
138
|
+
self.body.original_widget = self.views[self._active].widget
|
|
139
|
+
self._update_tab_bar()
|
|
140
|
+
self.set_hints(self.views[self._active].keyhints())
|
|
141
|
+
if not self.views[self._active]._loaded:
|
|
142
|
+
self.trigger_async_refresh()
|
|
143
|
+
|
|
144
|
+
def _input(self, key: str) -> None:
|
|
145
|
+
if self._confirm_yes is not None:
|
|
146
|
+
# Modal: only y/n/esc are live; tab/q/everything else is swallowed so
|
|
147
|
+
# a destructive confirm can't be skipped past by an accidental key.
|
|
148
|
+
if key == "y":
|
|
149
|
+
cb = self._confirm_yes
|
|
150
|
+
self._close_confirm()
|
|
151
|
+
cb()
|
|
152
|
+
elif key in ("n", "esc"):
|
|
153
|
+
self._close_confirm()
|
|
154
|
+
return
|
|
155
|
+
if key == "tab":
|
|
156
|
+
self._switch_tab()
|
|
157
|
+
elif key == "q":
|
|
158
|
+
self._exit()
|
|
159
|
+
else:
|
|
160
|
+
self.views[self._active].handle_key(key)
|
|
161
|
+
|
|
162
|
+
def confirm(self, message: str, on_yes: Callable[[], None]) -> None:
|
|
163
|
+
"""Show a y/n modal over the active tab; run `on_yes` only on `y`.
|
|
164
|
+
|
|
165
|
+
App-level (not a per-view mode) so every tab gets confirmation for free
|
|
166
|
+
and the view files stay under budget. While up, `_input` routes y/n/esc
|
|
167
|
+
and swallows the rest. The overlay sits ABOVE the view widget in
|
|
168
|
+
`self.body`, so a worker-thread refresh (which only rebuilds a view's own
|
|
169
|
+
walker) never disturbs it.
|
|
170
|
+
"""
|
|
171
|
+
self._confirm_yes = on_yes
|
|
172
|
+
self._confirm_base = self.body.original_widget
|
|
173
|
+
text = urwid.Text(f" {message}\n\n y 确认 n / Esc 取消")
|
|
174
|
+
box = urwid.AttrMap(urwid.LineBox(urwid.Filler(text)), "notify")
|
|
175
|
+
self.body.original_widget = urwid.Overlay(
|
|
176
|
+
box, self._confirm_base,
|
|
177
|
+
align="center", width=("relative", 50),
|
|
178
|
+
valign="middle", height=7,
|
|
179
|
+
)
|
|
180
|
+
self.footer_text.set_text(" y 确认 · n/Esc 取消")
|
|
181
|
+
|
|
182
|
+
def _close_confirm(self) -> None:
|
|
183
|
+
if self._confirm_base is not None:
|
|
184
|
+
self.body.original_widget = self._confirm_base
|
|
185
|
+
self._confirm_base = None
|
|
186
|
+
self._confirm_yes = None
|
|
187
|
+
self.set_hints(self.views[self._active].keyhints())
|
|
188
|
+
|
|
189
|
+
def _exit(self, result: tuple | None = None) -> None:
|
|
190
|
+
self._exiting = True
|
|
191
|
+
if self._alarm_handle:
|
|
192
|
+
self.loop.remove_alarm(self._alarm_handle)
|
|
193
|
+
self.result = result
|
|
194
|
+
raise urwid.ExitMainLoop()
|
|
195
|
+
|
|
196
|
+
def exit_with_resume(self, session: object, fork: bool = False) -> None:
|
|
197
|
+
self._exit(("resume", session, fork))
|
|
198
|
+
|
|
199
|
+
def set_hints(self, hints: str) -> None:
|
|
200
|
+
"""Footer = shared prefix + the active tab's keyhints (D1 single source)."""
|
|
201
|
+
self.footer_text.set_text(FOOTER_PREFIX + hints)
|
|
202
|
+
|
|
203
|
+
def notify(self, msg: str, seconds: float = 3) -> None:
|
|
204
|
+
self.frame.footer = urwid.AttrMap(urwid.Text(f" {msg}"), "notify")
|
|
205
|
+
self.loop.set_alarm_in(seconds, lambda *_: self._restore_footer())
|
|
206
|
+
|
|
207
|
+
def _restore_footer(self) -> None:
|
|
208
|
+
self.frame.footer = self.footer
|
|
209
|
+
|
|
210
|
+
def _run_fetch_cycle(self) -> None:
|
|
211
|
+
"""Worker-phase of a refresh — the synchronous, testable seam (R11/D8).
|
|
212
|
+
|
|
213
|
+
Computes ONE shared world snapshot per cycle so the three tabs don't each
|
|
214
|
+
re-scan /proc + transcripts, then projects it into every view's `_pending`
|
|
215
|
+
via `fetch_pending(snapshot)`. A failed build degrades to per-view
|
|
216
|
+
self-fetch (`snapshot=None`). Pure data side: it only sets `_pending`
|
|
217
|
+
fields and NEVER touches widgets, so it is safe on the worker thread and
|
|
218
|
+
can be driven directly in tests without a MainLoop.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
snapshot: WorldSnapshot | None = build_world_snapshot()
|
|
222
|
+
except Exception:
|
|
223
|
+
snapshot = None
|
|
224
|
+
for v in self.views:
|
|
225
|
+
v.fetch_pending(snapshot)
|
|
226
|
+
|
|
227
|
+
def trigger_async_refresh(self) -> None:
|
|
228
|
+
if self._refreshing or self._exiting:
|
|
229
|
+
return
|
|
230
|
+
self._refreshing = True
|
|
231
|
+
|
|
232
|
+
def worker() -> None:
|
|
233
|
+
try:
|
|
234
|
+
self._run_fetch_cycle()
|
|
235
|
+
finally:
|
|
236
|
+
self._refreshing = False
|
|
237
|
+
if self._pipe_fd is not None:
|
|
238
|
+
try:
|
|
239
|
+
os.write(self._pipe_fd, b"1")
|
|
240
|
+
except OSError:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
244
|
+
|
|
245
|
+
def _schedule_refresh(self, loop: object = None, data: object = None) -> None:
|
|
246
|
+
if self._exiting:
|
|
247
|
+
return
|
|
248
|
+
self.trigger_async_refresh()
|
|
249
|
+
self._alarm_handle = self.loop.set_alarm_in(10, self._schedule_refresh)
|
|
250
|
+
|
|
251
|
+
def _on_pipe(self, data: bytes) -> bool:
|
|
252
|
+
if not self._exiting:
|
|
253
|
+
for view in self.views:
|
|
254
|
+
view.apply_data()
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
def run(self) -> tuple | None:
|
|
258
|
+
self._pipe_fd = self.loop.watch_pipe(self._on_pipe)
|
|
259
|
+
self.views[self._active].load()
|
|
260
|
+
self.set_hints(self.views[self._active].keyhints())
|
|
261
|
+
self.trigger_async_refresh()
|
|
262
|
+
self._alarm_handle = self.loop.set_alarm_in(10, self._schedule_refresh)
|
|
263
|
+
self.loop.run()
|
|
264
|
+
return self.result
|