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.
@@ -0,0 +1,3 @@
1
+ """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m cc_session_control`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
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