cc-session-control 0.6.5__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {cc_session_control-0.6.5/src/cc_session_control.egg-info → cc_session_control-0.7.0}/PKG-INFO +3 -3
  2. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/README.md +2 -2
  3. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py +5 -1
  5. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py +38 -54
  6. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/app.py +6 -3
  7. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/sessions.py +39 -3
  8. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/tmux.py +27 -7
  9. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/models.py +5 -0
  10. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/skill/SKILL.md +3 -3
  11. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_confirm.py +36 -1
  12. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_session_row.py +10 -3
  13. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/agents.py +22 -4
  14. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/rc.py +9 -6
  15. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/sessions.py +34 -42
  16. {cc_session_control-0.6.5 → cc_session_control-0.7.0/src/cc_session_control.egg-info}/PKG-INFO +3 -3
  17. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_agent_ops.py +6 -0
  18. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_agents.py +55 -24
  19. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_app.py +26 -9
  20. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_data.py +72 -88
  21. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_sessions.py +45 -0
  22. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_skill_ops.py +6 -4
  23. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_views.py +122 -33
  24. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/LICENSE +0 -0
  25. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/pyproject.toml +0 -0
  26. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/setup.cfg +0 -0
  27. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/__main__.py +0 -0
  28. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/__init__.py +0 -0
  29. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/resume_list.py +0 -0
  30. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/actions/skill_ops.py +0 -0
  31. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/cli.py +0 -0
  32. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/clipboard.py +0 -0
  33. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/config.py +0 -0
  34. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/__init__.py +0 -0
  35. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/cleanup.py +0 -0
  36. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/environments.py +0 -0
  37. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/liveness.py +0 -0
  38. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/proc.py +0 -0
  39. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/rc.py +0 -0
  40. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/registry.py +0 -0
  41. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/data/snapshot.py +0 -0
  42. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/__init__.py +0 -0
  43. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_base.py +0 -0
  44. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_colspec.py +0 -0
  45. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_keytable.py +0 -0
  46. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_rows.py +0 -0
  47. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
  48. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/SOURCES.txt +0 -0
  49. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  50. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  51. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/requires.txt +0 -0
  52. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
  53. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_cleanup.py +0 -0
  54. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_cli.py +0 -0
  55. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_environments.py +0 -0
  56. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_liveness.py +0 -0
  57. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_proc.py +0 -0
  58. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_rc.py +0 -0
  59. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_registry.py +0 -0
  60. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_resume_list.py +0 -0
  61. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_smoke.py +0 -0
  62. {cc_session_control-0.6.5 → cc_session_control-0.7.0}/tests/test_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.6.5
3
+ Version: 0.7.0
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -29,8 +29,8 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
29
29
 
30
30
  ## Features
31
31
 
32
- - **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
33
- - **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
32
+ - **Sessions Tab** — View, resume (tmux-first: `Enter` resumes into the per-project tmux window; `t` bare-terminal fallback; `R` backgrounds into tmux; marks tmux-resident sessions), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
33
+ - **Projects Tab** — The startup tab: start a new tmux claude session in a project dir (`Enter`), start/stop RC servers per project (`o`/`s`), toggle auto-start, show running/stopped/dead states
34
34
  - **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
35
35
 
36
36
  Built with [urwid](https://urwid.org/).
@@ -6,8 +6,8 @@ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Contro
6
6
 
7
7
  ## Features
8
8
 
9
- - **Sessions Tab** — View, resume (bare terminal / tmux / background + Remote Control), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
10
- - **Projects Tab** — Start a new tmux claude session in a project dir (`t`), start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
9
+ - **Sessions Tab** — View, resume (tmux-first: `Enter` resumes into the per-project tmux window; `t` bare-terminal fallback; `R` backgrounds into tmux; marks tmux-resident sessions), terminate, and delete Claude Code sessions across all projects; a cleanup submenu (`c`) prunes empty/short sessions and sweeps orphan artifact directories
10
+ - **Projects Tab** — The startup tab: start a new tmux claude session in a project dir (`Enter`), start/stop RC servers per project (`o`/`s`), toggle auto-start, show running/stopped/dead states
11
11
  - **Background agents Tab** — List background agent jobs; take over, respawn, watch their timeline, stop, or remove them
12
12
 
13
13
  Built with [urwid](https://urwid.org/).
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.6.5"
3
+ __version__ = "0.7.0"
@@ -120,7 +120,10 @@ def resume_takeover(job: AgentJob) -> Session:
120
120
  all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
121
121
  `pid`/`alive` come from the host join so a live worker is killed first
122
122
  (resume = takeover); `current` is computed so the launching session stays
123
- protected. Does NOT itself replace the csctl process.
123
+ protected. `tmux_target` is filled here at action time (the agents list
124
+ renders no ⧉ badge, so there is no batch snapshot value to reuse) so the
125
+ tmux-first Enter can enter a resident worker in place. Does NOT itself
126
+ replace the csctl process.
124
127
  """
125
128
  pid, alive = job_host(job)
126
129
  current = bool(pid) and pid in proc.ancestor_pids()
@@ -136,6 +139,7 @@ def resume_takeover(job: AgentJob) -> Session:
136
139
  proc_start=_host_start(pid),
137
140
  source="bg",
138
141
  agent_short=job.short,
142
+ tmux_target=tmux.find_session_window([pid]) if alive and pid else None,
139
143
  )
140
144
 
141
145
 
@@ -87,7 +87,7 @@ def would_take_over(s: Session, fork: bool = False) -> bool:
87
87
  The single source of the "needs confirmation" decision for the UI: it reads
88
88
  `_resume_plan`'s `should_kill` so views never re-derive `s.alive and not
89
89
  s.current` themselves (CLAUDE.md: should_kill is single-point — re-derivation
90
- was the old divergence). `do_resume`/`relaunch_in_tmux` and the confirm gate
90
+ was the old divergence). `do_resume`/`do_tmux_resume` and the confirm gate
91
91
  thus agree by construction.
92
92
  """
93
93
  return _resume_plan(s, fork)[2]
@@ -126,79 +126,60 @@ def do_resume(s: Session, fork: bool = False) -> None:
126
126
  os.execvp("claude", args)
127
127
 
128
128
 
129
- def _rc_name(s: Session) -> str:
130
- """Remote-control label (shown in claude.ai/code) for a relaunched session."""
131
- base = s.cwd.rstrip("/").rsplit("/", 1)[-1] if s.cwd else ""
132
- return f"{base or 'session'}-{s.sid[:8]}"
133
-
134
-
135
- def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
136
- """Shell command that resumes the session under remote control."""
137
- cwd, args, _ = _resume_plan(s, fork)
138
- args = args + ["--remote-control", _rc_name(s)]
139
- line = shlex.join(args)
140
- return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
141
-
142
-
143
129
  def _spawn_in_tmux(s: Session, cmd: str, fork: bool = False) -> str | None:
144
130
  """Kill-if-takeover, then spawn `cmd` in the session's per-project tmux
145
- session — the shared skeleton behind `relaunch_in_tmux` (R key, with
146
- --remote-control) and `do_tmux_resume` (t key, foreground): the two
147
- differed only in the command builder. Returns the exact tmux target,
148
- or None on R10 refusal ("refused" from `take_over`) / tmux failure;
149
- "gone"/"failed" fall through like `do_resume` (best-effort kill).
131
+ session — the shared skeleton behind `do_tmux_resume` (Enter/f/R, the
132
+ tmux-first dispatch verbs, ADR-0001). A fork is a copy (never kills) and
133
+ gets its own `<sid8>-fork` window so it doesn't shadow the original's.
134
+ Returns the exact tmux target, or None on R10 refusal ("refused" from
135
+ `take_over`) / tmux failure; "gone"/"failed" fall through like
136
+ `do_resume` (best-effort kill).
150
137
  """
151
138
  _, _, should_kill = _resume_plan(s, fork)
152
139
  if should_kill and s.pid and take_over(s.pid, s.proc_start) == "refused":
153
140
  return None
154
- return tmux.run_in_tmux(tmux.session_name_for(s.cwd), s.sid[:8], cmd)
155
-
156
-
157
- def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
158
- """Relaunch a session as `claude --resume … --remote-control …` inside a
159
- tmux window, so it outlives the terminal and is remotely controllable.
160
-
161
- A live, non-current session is taken over (old pid killed first, like
162
- terminate); a fork leaves the original running. csctl is NOT replaced —
163
- it just spawns the tmux window.
164
- """
165
- return _spawn_in_tmux(s, tmux_resume_cmd(s, fork), fork=fork) is not None
141
+ window = f"{s.sid[:8]}-fork" if fork else s.sid[:8]
142
+ return tmux.run_in_tmux(tmux.session_name_for(s.cwd), window, cmd)
166
143
 
167
144
 
168
145
  def attach_target(s: Session) -> str | None:
169
146
  """The tmux window ("session:index") already hosting this live session.
170
147
 
171
- Non-None means the `t` key can attach directly no kill, no respawn (the
172
- session survives in place). None for dead sessions or live ones running in
173
- a bare terminal (those need `do_tmux_resume`)."""
174
- if not s.alive or not s.pid:
148
+ Non-None means the session is tmux-resident and can be entered in place
149
+ no kill, no respawn. Reads the snapshot-computed `Session.tmux_target`
150
+ (the SAME data the badge renders — one source, no per-action
151
+ re-detection), guarded on `alive` so a stale target on a dead session
152
+ never answers."""
153
+ if not s.alive:
175
154
  return None
176
- return tmux.find_session_window([s.pid])
155
+ return s.tmux_target
177
156
 
178
157
 
179
- def tmux_foreground_cmd(s: Session) -> str:
180
- """Shell command for the `t`-key window: plain resume, NO --remote-control.
158
+ def tmux_foreground_cmd(s: Session, fork: bool = False) -> str:
159
+ """Shell command for a tmux-dispatch window: plain resume, NO
160
+ --remote-control.
181
161
 
182
- Deliberately unlike `tmux_resume_cmd` (the `R` key): a foreground tmux
183
- resume only needs the disconnect safety net; every --remote-control process
184
- mints a new cloud environment entry, which piles up with frequent use."""
185
- cwd, args, _ = _resume_plan(s)
162
+ Deliberate (ADR-0001): tmux residency is the anti-disconnect mechanism;
163
+ every --remote-control process mints a new cloud environment entry, which
164
+ piles up with frequent use the Sessions tab never mints cloud envs."""
165
+ cwd, args, _ = _resume_plan(s, fork)
186
166
  line = shlex.join(args)
187
167
  return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
188
168
 
189
169
 
190
- def do_tmux_resume(s: Session) -> str | None:
191
- """Kill-if-takeover, spawn the resume window in the session's per-project
192
- tmux session, and return the exact tmux target to enter; None on failure.
193
- The caller (cli) then calls `enter_window` on the target."""
194
- return _spawn_in_tmux(s, tmux_foreground_cmd(s))
170
+ def do_tmux_resume(s: Session, fork: bool = False) -> str | None:
171
+ """Kill-if-takeover (fork never kills), spawn the resume window in the
172
+ session's per-project tmux session, and return the exact tmux target;
173
+ None on failure. Enter/f enter the target afterwards (`TmuxResumeIntent`);
174
+ R 转后台 spawns it and stays in csctl."""
175
+ return _spawn_in_tmux(s, tmux_foreground_cmd(s, fork), fork=fork)
195
176
 
196
177
 
197
178
  def do_tmux_new(directory: str) -> str | None:
198
179
  """Start a NEW claude session in `directory`, inside that project's own
199
180
  tmux session, and return the exact tmux target to enter; None on failure.
200
181
 
201
- The 项目-tab `t` key: same skeleton as `do_tmux_resume` but nothing exists
182
+ The 项目-tab Enter key: same skeleton as `do_tmux_resume` but nothing exists
202
183
  yet — no kill, no confirm, no R10 gate (no process is terminated). Plain
203
184
  `claude` with NO --remote-control (same tradeoff as `tmux_foreground_cmd`:
204
185
  every RC process mints a new cloud environment entry). No trust gate
@@ -226,7 +207,8 @@ class ExitIntent:
226
207
 
227
208
  @dataclass(frozen=True)
228
209
  class ResumeIntent(ExitIntent):
229
- """Enter/f: bare-terminal resume (execvp; takeover kill inside do_resume)."""
210
+ """`t` 终端接回: bare-terminal resume (execvp; takeover kill inside
211
+ do_resume) — the fallback when tmux is unavailable or unwanted."""
230
212
 
231
213
  session: Session
232
214
  fork: bool = False
@@ -237,7 +219,7 @@ class ResumeIntent(ExitIntent):
237
219
 
238
220
  @dataclass(frozen=True)
239
221
  class AttachIntent(ExitIntent):
240
- """`t` on a tmux-hosted session: enter its window in place (no kill)."""
222
+ """Enter on a tmux-resident session: enter its window in place (no kill)."""
241
223
 
242
224
  target: str
243
225
 
@@ -248,12 +230,14 @@ class AttachIntent(ExitIntent):
248
230
 
249
231
  @dataclass(frozen=True)
250
232
  class TmuxResumeIntent(ExitIntent):
251
- """`t` on a dead / bare-terminal session: resume inside tmux, then enter."""
233
+ """Enter/f on a dead / bare-terminal session: resume (or fork) inside
234
+ tmux, then enter — the primary tmux-first dispatch (ADR-0001)."""
252
235
 
253
236
  session: Session
237
+ fork: bool = False
254
238
 
255
239
  def run(self) -> None:
256
- target = do_tmux_resume(self.session)
240
+ target = do_tmux_resume(self.session, fork=self.fork)
257
241
  if target is None:
258
242
  print("Failed to resume the session inside tmux (R10 degraded, or tmux unavailable).")
259
243
  elif not enter_window(target):
@@ -262,7 +246,7 @@ class TmuxResumeIntent(ExitIntent):
262
246
 
263
247
  @dataclass(frozen=True)
264
248
  class TmuxNewIntent(ExitIntent):
265
- """项目-tab `t`: start a NEW claude in tmux, then enter (pure spawn)."""
249
+ """项目-tab Enter: start a NEW claude in tmux, then enter (pure spawn)."""
266
250
 
267
251
  directory: str
268
252
 
@@ -69,7 +69,9 @@ PALETTE = [
69
69
  ("col_header", "dark cyan", "black", None, "#9cc", "#181818"),
70
70
  ]
71
71
 
72
- TAB_NAMES = ["会话", "项目", "后台"]
72
+ # Launcher-first order (ADR-0001): startup lands on 项目 (the tmux-first
73
+ # dispatch entry), then 会话 / 后台. In lockstep with `self.views` below.
74
+ TAB_NAMES = ["项目", "会话", "后台"]
73
75
 
74
76
  # D1: all three tabs share ONE footer prefix — the universal verbs (Tab/q/r) live
75
77
  # here exactly once so `r 刷新` shows identically on every tab. View-specific keys
@@ -104,8 +106,9 @@ class App:
104
106
  self._confirm_yes: Callable[[], None] | None = None
105
107
  self._confirm_base: urwid.Widget | None = None
106
108
 
107
- # Order is in lockstep with TAB_NAMES: 会话 / 项目 / 后台.
108
- self.views: list[TabView] = [SessionsView(self), RCView(self), AgentsView(self)]
109
+ # Order is in lockstep with TAB_NAMES: 项目 / 会话 / 后台 (launcher-first,
110
+ # ADR-0001 startup loads the 项目 tab at _active=0).
111
+ self.views: list[TabView] = [RCView(self), SessionsView(self), AgentsView(self)]
109
112
  self._active = 0
110
113
 
111
114
  self.body = urwid.WidgetPlaceholder(self.views[0].widget)
@@ -8,7 +8,7 @@ import os
8
8
 
9
9
  from ..config import cfg
10
10
  from ..models import LiveInfo, Session
11
- from . import registry
11
+ from . import registry, tmux
12
12
  from .liveness import alive_map, is_rc_exposed, live_index, live_session_procs
13
13
  from .proc import ancestor_pids as _ancestor_pids # /proc walk moved to proc.py
14
14
 
@@ -160,14 +160,49 @@ def _parse_transcript(
160
160
  )
161
161
 
162
162
 
163
+ def _candidate_pids(info: LiveInfo | None) -> list[int]:
164
+ """A LiveInfo's pid candidate set — `pids` when filled, else the chosen pid
165
+ (same fallback rule the `current` check in `_parse_transcript` uses)."""
166
+ if info is None:
167
+ return []
168
+ if info.pids:
169
+ return info.pids
170
+ return [info.pid] if info.pid else []
171
+
172
+
173
+ def _inject_tmux_residency(rows: list[Session], idx: dict[str, LiveInfo]) -> None:
174
+ """Fill `Session.tmux_target` for every ALIVE session, in ONE batch.
175
+
176
+ Collects all alive sessions' candidate pids, calls
177
+ `tmux.residency_targets` once (one `list-panes -a` per scan cycle), and
178
+ backfills each session with its first hit — any alive pid inside a tmux
179
+ pane makes the session resident (ADR-0001). Dead sessions stay None; the
180
+ badge and the resume/backgrounding actions read this SAME field, so there
181
+ is no per-action re-detection (no second source of truth)."""
182
+ alive_pids = {
183
+ pid for row in rows if row.alive for pid in _candidate_pids(idx.get(row.sid))
184
+ }
185
+ targets = tmux.residency_targets(alive_pids)
186
+ if not targets:
187
+ return
188
+ for row in rows:
189
+ if not row.alive:
190
+ continue
191
+ for pid in _candidate_pids(idx.get(row.sid)):
192
+ if pid in targets:
193
+ row.tmux_target = targets[pid]
194
+ break
195
+
196
+
163
197
  def scan() -> list[Session]:
164
198
  """Unified transcript-driven session scan.
165
199
 
166
200
  Merges the three liveness/identity sources once per scan — registry
167
201
  `sessions/<pid>.json`, `claude agents --json`, and `jobs/*/state.json` — then
168
202
  projects each transcript through `live_index()` to fill source/liveness/
169
- rc-exposure. Scan stays transcript-driven: an agent-only sid (present in the
170
- live index but with no transcript) is surfaced by the Agents tab, not here.
203
+ rc-exposure, and batch-injects tmux residency (`tmux_target`). Scan stays
204
+ transcript-driven: an agent-only sid (present in the live index but with no
205
+ transcript) is surfaced by the Agents tab, not here.
171
206
  """
172
207
  root = str(cfg.projects_root)
173
208
  session_procs = live_session_procs()
@@ -182,5 +217,6 @@ def scan() -> list[Session]:
182
217
  if row is not None:
183
218
  rows.append(row)
184
219
 
220
+ _inject_tmux_residency(rows, idx)
185
221
  rows.sort(key=lambda r: r.mtime, reverse=True)
186
222
  return rows
@@ -16,6 +16,7 @@ don't conflate tmux windows with Remote Control).
16
16
  from __future__ import annotations
17
17
 
18
18
  import subprocess
19
+ from collections.abc import Iterable
19
20
 
20
21
  from . import proc
21
22
 
@@ -170,18 +171,37 @@ def window_containing(
170
171
  return None
171
172
 
172
173
 
173
- def find_session_window(pids: list[int]) -> str | None:
174
- """The tmux window ("session:index") hosting any of `pids`, or None.
174
+ def residency_targets(pids: Iterable[int]) -> dict[int, str]:
175
+ """{pid: "session:window_index"} for every pid hosted by a tmux pane.
175
176
 
176
- Walks each pid's `/proc` ancestor chain and matches it against every tmux
177
- pane's root pid finds windows in ANY tmux session (cc, rc, user-made)."""
177
+ THE batch tmux-residency computation (ADR-0001 badge + actions share it):
178
+ ONE `list-panes -a` for the whole pid set, then each pid's `/proc` ancestor
179
+ chain is matched against the pane root pids — finds windows in ANY tmux
180
+ session (per-project, rc, user-made). Misses are simply absent from the
181
+ dict; tmux failure returns {} (swallow-errors contract)."""
182
+ pid_list = list(pids)
183
+ if not pid_list:
184
+ return {}
178
185
  panes = _tmux_list_all_panes()
179
186
  if not panes:
180
- return None
181
- for pid in pids:
187
+ return {}
188
+ out: dict[int, str] = {}
189
+ for pid in pid_list:
182
190
  target = window_containing(panes, proc.ancestors_of(pid))
183
191
  if target:
184
- return target
192
+ out[pid] = target
193
+ return out
194
+
195
+
196
+ def find_session_window(pids: list[int]) -> str | None:
197
+ """The tmux window ("session:index") hosting any of `pids`, or None.
198
+
199
+ Single-target convenience over `residency_targets` (first hit in `pids`
200
+ order) — used by the action layer when only one session is in play."""
201
+ targets = residency_targets(pids)
202
+ for pid in pids:
203
+ if pid in targets:
204
+ return targets[pid]
185
205
  return None
186
206
 
187
207
 
@@ -34,6 +34,11 @@ class Session:
34
34
  env_id: str | None = None # bound bridge environment id, if any
35
35
  agent_short: str | None = None # linked background-agent short id, if any
36
36
  status: str = "" # registry `status` (busy / idle)
37
+ # tmux residency (CONTEXT.md / ADR-0001): non-None means a live pid of this
38
+ # session runs inside a tmux pane; the value is the enterable
39
+ # "session:window_index" target. Batch-computed in sessions.scan() via
40
+ # tmux.residency_targets — actions and the ⧉ badge read the SAME field.
41
+ tmux_target: str | None = None
37
42
 
38
43
  @property
39
44
  def bridge_or_sdk(self) -> bool:
@@ -12,10 +12,10 @@ description: Diagnose and rescue local Claude Code sessions via csctl — list l
12
12
  ## 交互式 TUI
13
13
 
14
14
  ```bash
15
- csctl # 三个 tab:会话 / 项目(远控 + t 新建 tmux 会话)/ 后台 agent;清理在会话 tab 的子菜单
15
+ csctl # 三个 tab(tmux-first,启动落在项目 tab):项目(Enter 新建 tmux 会话 / o 启动远控)/ 会话 / 后台 agent;清理在会话 tab 的子菜单
16
16
  ```
17
17
 
18
- 会话 tab 键位:方向键移动 · `/` 过滤 · `Enter` 接回(真接续,自动 cd+kill,接活会话先确认)· `t` tmux 接回(进 tmux 窗口,断线不死)· `f` 分叉接回 · `s` 停止活会话(确认) · `R` 转入后台+远控(确认) · `d` 删除已结束会话 · `y` 复制命令到剪贴板 · `h` 桥接/SDK 显隐 · `c` 清理子菜单 · `r` 刷新 · `q` 退出。接回时 csctl exec 成 claude 接管终端。
18
+ 会话 tab 键位:方向键移动 · `/` 过滤 · `Enter` tmux 接回(主操作:恢复进 per-project tmux 窗口并进入,断线不死;已驻留 会话就地进入;接活会话先确认)· `t` 终端接回(裸终端兜底,真接续自动 cd+kill)· `f` 分叉进 tmux · `s` 停止活会话(确认) · `R` 转后台(进 tmux 不进入、不开远控,留在 csctl)· `d` 删除已结束会话 · `y` 复制命令到剪贴板 · `h` 桥接/SDK 显隐 · `c` 清理子菜单 · `r` 刷新 · `q` 退出。接回 = 离开 csctl(attach 进 tmux 或 exec 成 claude)。
19
19
 
20
20
  ## 心智模型:会话形态
21
21
 
@@ -63,7 +63,7 @@ csctl resume [关键词] --all # 不分页全列
63
63
 
64
64
  ## 接管远程控制 / 网页会话到本地
65
65
 
66
- `claude --remote-control` 或 claude.ai 网页控制的会话,在本机就是一个普通的**活会话**(会进 `claude agents`)。想搬到本地终端接管,照「接回规则」当"还活着"处理即可;`csctl resume` 已自动给出对应命令。TUI 里也可以选中后接回或 relaunch tmux(`--remote-control` 恢复远控暴露)。
66
+ `claude --remote-control` 或 claude.ai 网页控制的会话,在本机就是一个普通的**活会话**(会进 `claude agents`)。想搬到本地终端接管,照「接回规则」当"还活着"处理即可;`csctl resume` 已自动给出对应命令。TUI 里选中后 `Enter` tmux 接回(或 `t` 终端接回)。注意 0.7 起会话 tab **不再提供带 `--remote-control` 的 relaunch**(每个远控进程都会新铸一个云端环境);要恢复远控暴露,用项目 tab 的 `o`/`c` 或会话内 `/remote-control`。
67
67
 
68
68
  ## 清理残留 bg agent
69
69
 
@@ -12,7 +12,12 @@ from __future__ import annotations
12
12
  from collections.abc import Callable
13
13
  from typing import TYPE_CHECKING
14
14
 
15
- from ..actions.session_ops import would_take_over
15
+ from ..actions.session_ops import (
16
+ AttachIntent,
17
+ TmuxResumeIntent,
18
+ attach_target,
19
+ would_take_over,
20
+ )
16
21
  from ..data import proc
17
22
  from ._rows import CONFIRM_NAME_CELLS, truncate_cells
18
23
 
@@ -89,3 +94,33 @@ def confirm_takeover(
89
94
  return
90
95
  shown = truncate_cells(s.label if name is None else name, CONFIRM_NAME_CELLS)
91
96
  app.confirm(f"{verb}「{shown}」?将先终止原进程。", on_yes)
97
+
98
+
99
+ def confirm_tmux_takeover(
100
+ app: App,
101
+ s: Session,
102
+ verb: str,
103
+ *,
104
+ fork: bool = False,
105
+ name: str | None = None,
106
+ ) -> None:
107
+ """The tmux-first Enter/f body, shared by the 会话/后台 tabs (ADR-0001).
108
+
109
+ A tmux-resident session is entered IN PLACE (`AttachIntent` — no kill, no
110
+ confirm, no R10 gate: nothing destructive happens). Anything else goes
111
+ through the standard takeover gate (`confirm_takeover`) into
112
+ `TmuxResumeIntent` — resume (or fork) inside its per-project tmux window,
113
+ then enter. A fork is a copy: it never enters the original's window in
114
+ place, it always spawns its own (and never kills, so the confirm path
115
+ falls straight through).
116
+ """
117
+ if not fork:
118
+ target = attach_target(s)
119
+ if target:
120
+ app.exit_with(AttachIntent(target))
121
+ return
122
+ confirm_takeover(
123
+ app, s, verb,
124
+ lambda: app.exit_with(TmuxResumeIntent(s, fork=fork)),
125
+ name=name, fork=fork,
126
+ )
@@ -34,7 +34,7 @@ _SOURCE_BADGES = {
34
34
  # left; the numeric 提问 and the ragged relative 时间 right-align so their line-
35
35
  # to-line anchor is stable.
36
36
  SESSION_COLS = [
37
- (6, "left", "状态"),
37
+ (8, "left", "状态"),
38
38
  (4, "left", "来源"),
39
39
  (4, "left", "远控"),
40
40
  (11, "right", "时间"),
@@ -74,11 +74,18 @@ def _status_parts(session: Session) -> tuple[str, str]:
74
74
  meaning (忙 = generating/tool-running, 闲 = waiting for input, 停 = no
75
75
  process), the ●/○ shape survives colorless terminals, and only the
76
76
  established ●=on / ○=off convention is used (no ◐ — ambiguous, and an
77
- East-Asian-Ambiguous width risk, the P5 glyph lesson)."""
77
+ East-Asian-Ambiguous width risk, the P5 glyph lesson).
78
+
79
+ A live tmux-resident session (ADR-0001) additionally shows the ⧉ badge —
80
+ U+29C9 is East_Asian_Width=Neutral (width-stable 1 cell, verified against
81
+ wcwidth + urwid.calc_width, the P5 check), unlike the ambiguous glyphs the
82
+ P5 lesson banned. Data comes from the snapshot's `tmux_target`; the resume
83
+ actions read the SAME field."""
78
84
  cur = "▸" if session.current else " "
79
85
  if session.alive:
80
86
  word = "忙" if session.status == "busy" else "闲"
81
- return f"{cur}● {word}", ("status_busy" if word == "忙" else "alive")
87
+ badge = " " if session.tmux_target else ""
88
+ return f"{cur}● {word}{badge}", ("status_busy" if word == "忙" else "alive")
82
89
  return f"{cur}○ 停", "dead"
83
90
 
84
91
 
@@ -21,7 +21,7 @@ from ..models import AgentJob
21
21
  from ._base import ListTabView
22
22
  from ._colspec import header_columns, row_columns
23
23
  from ._confirm import DEGRADED as _DEGRADED
24
- from ._confirm import confirm_stop, confirm_takeover
24
+ from ._confirm import confirm_stop, confirm_takeover, confirm_tmux_takeover
25
25
  from ._keytable import HelpLayout, Key, footer_hints, help_lines
26
26
  from ._rows import TextRow
27
27
 
@@ -77,9 +77,15 @@ class AgentsView(ListTabView):
77
77
  # help, and dispatch are generated from this table. `r 刷新` stays in the
78
78
  # App-level FOOTER_PREFIX, so its entry is hint-less.
79
79
  KEY_TABLE = (
80
- Key(("enter", "o"), "Enter/o 接回", "_takeover",
80
+ Key(("enter",), "Enter 接回", "_takeover",
81
81
  section="后台 agent 生命周期:", help_lines=(
82
- " Enter/o 接回(拉回前台,复用 resume;接运行中的 agent 会先确认接管)",
82
+ " Enter tmux 接回(恢复进 tmux 窗口并接入前台,断线不死;",
83
+ " 已驻留 tmux 的 agent 就地进入;接运行中的会先确认接管)",
84
+ )),
85
+ Key(("t",), "t 终端接回", "_terminal",
86
+ section="后台 agent 生命周期:", help_lines=(
87
+ " t 终端接回(在当前终端恢复,随终端关闭而结束——兜底;",
88
+ " 接运行中的会先确认接管)",
83
89
  )),
84
90
  Key(("s",), "s 停止", "_stop", section="后台 agent 生命周期:", help_lines=(
85
91
  " s 停止(仅运行中,需确认)",
@@ -187,14 +193,26 @@ class AgentsView(ListTabView):
187
193
  self.app.trigger_async_refresh()
188
194
 
189
195
  def _takeover(self, job: AgentJob) -> None:
196
+ """Enter — tmux 接回: a tmux-resident worker is entered in place;
197
+ otherwise resume it inside its per-project tmux window (ADR-0001)."""
190
198
  s = agent_ops.resume_takeover(job)
191
199
  if s.current:
192
200
  self.app.notify("不能接回当前会话")
193
201
  return
194
202
  # B1: takeover of a RUNNING worker kills its host pid (should_kill) — same
195
203
  # as Sessions Enter-live. A dead worker resumes directly, unconfirmed.
204
+ confirm_tmux_takeover(
205
+ self.app, s, "接回后台 agent", name=job.name or job.short,
206
+ )
207
+
208
+ def _terminal(self, job: AgentJob) -> None:
209
+ """t — 终端接回 (fallback): bare-terminal resume via the existing path."""
210
+ s = agent_ops.resume_takeover(job)
211
+ if s.current:
212
+ self.app.notify("不能接回当前会话")
213
+ return
196
214
  confirm_takeover(
197
- self.app, s, "接回后台 agent",
215
+ self.app, s, "终端接回后台 agent",
198
216
  lambda: self.app.exit_with(ResumeIntent(s)),
199
217
  name=job.name or job.short,
200
218
  )
@@ -2,8 +2,10 @@
2
2
 
3
3
  Shows two things:
4
4
  1. managed projects (RCProject) with the tri-state `remoteControlAtStartup`
5
- and `remoteControlSpawnMode`, plus the start/stop/autostart keys and `t`
6
- (start a NEW claude session in the project dir inside tmux, then enter it);
5
+ and `remoteControlSpawnMode`, plus Enter (start a NEW claude session in
6
+ the project dir inside tmux, then enter it — the tmux-first launcher,
7
+ ADR-0001), `o` (start the project RC server, the demoted secondary), and
8
+ the stop/autostart keys;
7
9
  2. project RC servers (RCServer) discovered via tmux ∪ /proc, badged
8
10
  managed/external — external servers are READ-ONLY (no takeover/restart key).
9
11
 
@@ -137,13 +139,14 @@ class RCView(ListTabView):
137
139
  # help, and dispatch are generated from this table. `r 刷新` stays in the
138
140
  # App-level FOOTER_PREFIX, so its entry is hint-less.
139
141
  KEY_TABLE = (
140
- Key(("t",), "t 新建会话", "_key_tmux_new",
142
+ Key(("enter",), "Enter 新建会话", "_key_tmux_new",
141
143
  section="项目操作(仅对「项目」行生效):", help_lines=(
142
- " t 在项目目录新建 tmux claude 会话并直接进入(离开 csctl",
144
+ " Enter 在项目目录新建 tmux claude 会话并直接进入(离开 csctl",
145
+ " tmux-first 主入口,会话默认获得断线保护)",
143
146
  )),
144
- Key(("enter",), "Enter 启动远控", "_key_start",
147
+ Key(("o",), "o 启动远控", "_key_start",
145
148
  section="项目操作(仅对「项目」行生效):", help_lines=(
146
- " Enter 启动选中项目的远程控制服务",
149
+ " o 启动选中项目的远程控制服务(手机/网页控制面,次要入口)",
147
150
  )),
148
151
  Key(("s",), "s 停止", "_key_stop",
149
152
  section="项目操作(仅对「项目」行生效):", help_lines=(