cc-session-control 0.6.4__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.4/src/cc_session_control.egg-info → cc_session_control-0.7.0}/PKG-INFO +3 -3
  2. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/README.md +2 -2
  3. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/agent_ops.py +9 -13
  5. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/session_ops.py +42 -58
  6. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/app.py +6 -3
  7. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/cli.py +15 -5
  8. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/cleanup.py +59 -71
  9. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/liveness.py +24 -0
  10. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/rc.py +15 -182
  11. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/sessions.py +39 -3
  12. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/snapshot.py +7 -30
  13. cc_session_control-0.7.0/src/cc_session_control/data/tmux.py +217 -0
  14. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/models.py +5 -0
  15. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/skill/SKILL.md +3 -3
  16. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_confirm.py +36 -1
  17. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_session_row.py +10 -3
  18. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/agents.py +22 -4
  19. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/rc.py +9 -6
  20. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/sessions.py +35 -43
  21. {cc_session_control-0.6.4 → cc_session_control-0.7.0/src/cc_session_control.egg-info}/PKG-INFO +3 -3
  22. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
  23. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_agent_ops.py +7 -1
  24. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_agents.py +55 -24
  25. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_app.py +26 -9
  26. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_cleanup.py +2 -2
  27. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_cli.py +4 -3
  28. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_data.py +97 -150
  29. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_sessions.py +45 -0
  30. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_skill_ops.py +6 -4
  31. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_snapshot.py +5 -5
  32. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_views.py +122 -33
  33. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/LICENSE +0 -0
  34. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/pyproject.toml +0 -0
  35. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/setup.cfg +0 -0
  36. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/__main__.py +0 -0
  37. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/__init__.py +0 -0
  38. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/resume_list.py +0 -0
  39. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/actions/skill_ops.py +0 -0
  40. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/clipboard.py +0 -0
  41. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/config.py +0 -0
  42. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/__init__.py +0 -0
  43. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/environments.py +0 -0
  44. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/proc.py +0 -0
  45. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/data/registry.py +0 -0
  46. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/__init__.py +0 -0
  47. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_base.py +0 -0
  48. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_colspec.py +0 -0
  49. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_keytable.py +0 -0
  50. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_rows.py +0 -0
  51. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
  52. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  53. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  54. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/requires.txt +0 -0
  55. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/src/cc_session_control.egg-info/top_level.txt +0 -0
  56. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_environments.py +0 -0
  57. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_liveness.py +0 -0
  58. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_proc.py +0 -0
  59. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_rc.py +0 -0
  60. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_registry.py +0 -0
  61. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_resume_list.py +0 -0
  62. {cc_session_control-0.6.4 → cc_session_control-0.7.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.6.4
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.4"
3
+ __version__ = "0.7.0"
@@ -25,7 +25,7 @@ import os
25
25
  import shlex
26
26
 
27
27
  from ..config import cfg
28
- from ..data import cleanup, liveness, proc, rc, registry
28
+ from ..data import cleanup, liveness, proc, registry, tmux
29
29
  from ..models import AgentJob, Session
30
30
  from . import session_ops
31
31
 
@@ -71,12 +71,12 @@ def respawn(job: AgentJob) -> str:
71
71
  """Relaunch a background agent in tmux; returns the exact command string.
72
72
 
73
73
  Runs `respawn_cmd(job)` in the job's per-project tmux session
74
- (`rc.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
74
+ (`tmux.session_name_for(job.cwd)`) so it outlives the terminal — it does NOT
75
75
  os.exec/replace the csctl process. The returned string also feeds the
76
76
  clipboard `y`-style key.
77
77
  """
78
78
  cmd = respawn_cmd(job)
79
- rc.run_in_tmux(rc.session_name_for(job.cwd), _job_window(job), cmd)
79
+ tmux.run_in_tmux(tmux.session_name_for(job.cwd), _job_window(job), cmd)
80
80
  return cmd
81
81
 
82
82
 
@@ -94,15 +94,7 @@ def remove_job(job: AgentJob) -> bool:
94
94
  _, alive = job_host(job)
95
95
  if alive:
96
96
  return False
97
- job_dir = os.path.join(str(cfg.jobs_dir), job.short)
98
- removed = cleanup._remove_path(job_dir)
99
- # Reuse cleanup's artifact-path helper / remover: it returns the sid-keyed
100
- # dirs (session-env/file-history/tasks/uploads) plus jobs/<sid[:8]> (which
101
- # usually equals job_dir — a second remove is a harmless no-op), so the job's
102
- # session leaves no orphan artifacts behind.
103
- for path in cleanup._session_artifact_paths(job.sid):
104
- cleanup._remove_path(path)
105
- return removed
97
+ return cleanup.remove_agent_artifacts(job.short, job.sid)
106
98
 
107
99
 
108
100
  # --- watch (read-only) --------------------------------------------------------
@@ -128,7 +120,10 @@ def resume_takeover(job: AgentJob) -> Session:
128
120
  all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
129
121
  `pid`/`alive` come from the host join so a live worker is killed first
130
122
  (resume = takeover); `current` is computed so the launching session stays
131
- 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.
132
127
  """
133
128
  pid, alive = job_host(job)
134
129
  current = bool(pid) and pid in proc.ancestor_pids()
@@ -144,6 +139,7 @@ def resume_takeover(job: AgentJob) -> Session:
144
139
  proc_start=_host_start(pid),
145
140
  source="bg",
146
141
  agent_short=job.short,
142
+ tmux_target=tmux.find_session_window([pid]) if alive and pid else None,
147
143
  )
148
144
 
149
145
 
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from typing import Literal
11
11
 
12
12
  from .. import clipboard
13
- from ..data import proc, rc
13
+ from ..data import proc, tmux
14
14
  from ..data.liveness import invalidate_cache
15
15
  from ..models import Session
16
16
 
@@ -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,86 +126,67 @@ 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 rc.run_in_tmux(rc.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 rc.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
205
186
  either: the user lands inside the window, so claude's own trust dialog
206
187
  shows interactively."""
207
188
  cmd = f"cd {shlex.quote(directory)} && claude"
208
- return rc.run_in_tmux(rc.session_name_for(directory), "claude", cmd)
189
+ return tmux.run_in_tmux(tmux.session_name_for(directory), "claude", cmd)
209
190
 
210
191
 
211
192
  # --- exit intents (the payload crossing the exit-then-exec seam) ------------
@@ -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
 
@@ -282,10 +266,10 @@ def enter_window(target: str) -> bool:
282
266
  return so the caller exits csctl normally — both paths end csctl, keeping
283
267
  "接回 = 离开 csctl" uniform. select-window failure is non-fatal (the user
284
268
  lands in the session and can pick the window by hand)."""
285
- rc.select_window(target)
269
+ tmux.select_window(target)
286
270
  session = target.split(":", 1)[0]
287
271
  if os.environ.get("TMUX"):
288
- return rc.switch_client(target)
272
+ return tmux.switch_client(target)
289
273
  try:
290
274
  os.execvp("tmux", ["tmux", "attach-session", "-t", session])
291
275
  except OSError:
@@ -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)
@@ -136,21 +136,31 @@ def _cmd_rc(args: argparse.Namespace) -> None:
136
136
 
137
137
 
138
138
  def _cmd_prune(args: argparse.Namespace) -> None:
139
+ from .data import liveness
139
140
  from .data.cleanup import (
140
- cleanup_stats,
141
+ build_plan,
141
142
  execute_orphan_removals,
142
143
  execute_session_removals,
143
- list_orphan_dirs,
144
144
  prune_sessions,
145
145
  )
146
146
  from .data.sessions import scan
147
147
 
148
148
  sessions = scan()
149
- stats = cleanup_stats(sessions)
150
- print(f"Total: {stats['total']} Empty: {stats['empty']} Short(<=2): {stats['short']} Orphans: {stats['orphans']}")
149
+ # One shared fetch feeds the frozen plan — the SAME assembly build_plan's
150
+ # other callers (the Sessions view, build_world_snapshot) use, so the CLI
151
+ # header and any future TUI parity stay derived from one source (删除 ⊆ 预览
152
+ # still holds: execute_* revalidate against fresh data at apply time).
153
+ procs, cur, jobs, agents = liveness.liveness_inputs()
154
+ plan = build_plan(sessions, procs, cur, jobs, agents)
155
+ counts = plan.counts()
156
+ print(
157
+ f"Total: {len(sessions)} Prunable empty: {counts['empty']} "
158
+ f"short(<=2): {counts['short']} Orphan dirs: {counts['orphan_dirs']} "
159
+ f"Zombie files: {counts['zombie_procs']} Aged: {counts['aged_entries']}"
160
+ )
151
161
 
152
162
  if args.sweep_orphans:
153
- orphans = list_orphan_dirs(sessions)
163
+ orphans = plan.orphan_entries
154
164
  print(f"Would sweep {len(orphans)} orphan artifact dir(s)")
155
165
  if not args.apply:
156
166
  print("Dry run. Add --apply to execute.")
@@ -80,9 +80,10 @@ def _session_artifact_paths(sid: str) -> list[str]:
80
80
  """All on-disk artifact paths owned by one session id (cfg-derived).
81
81
 
82
82
  Covers the sid-keyed dirs plus the 8-char-prefixed `jobs/<short>` dir for
83
- this session. Used by `agent_ops.remove_job` (which has already alive-gated
84
- the job). `remove_session` does NOT use this — it guards the `jobs/<short>`
85
- path separately so a LIVE agent worker's jobs dir is never deleted (M3).
83
+ this session. Used by `remove_agent_artifacts` (whose caller,
84
+ `agent_ops.remove_job`, has already alive-gated the job). `remove_session`
85
+ does NOT use this it guards the `jobs/<short>` path separately so a LIVE
86
+ agent worker's jobs dir is never deleted (M3).
86
87
  """
87
88
  return _sid_keyed_paths(sid) + [_jobs_path(sid)]
88
89
 
@@ -101,6 +102,26 @@ def _remove_path(path: str) -> bool:
101
102
  return False
102
103
 
103
104
 
105
+ def remove_agent_artifacts(short: str, sid: str) -> bool:
106
+ """Delete a settled background agent's `jobs/<short>` dir + sid artifacts.
107
+
108
+ Removes `jobs/<short>` first (the return value is True iff THAT removal
109
+ happened), then every path in `_session_artifact_paths(sid)` (the sid-keyed
110
+ dirs plus `jobs/<sid[:8]>` — a second remove of the same job dir when
111
+ `short == sid[:8]` is a harmless no-op).
112
+
113
+ The CALLER owns the gates: this function assumes the job has already been
114
+ alive-gated (a LIVE worker must never reach here) and that
115
+ `proc.current_determinable()` (R10) has already been checked — it only
116
+ deletes.
117
+ """
118
+ job_dir = os.path.join(str(cfg.jobs_dir), short)
119
+ removed = _remove_path(job_dir)
120
+ for path in _session_artifact_paths(sid):
121
+ _remove_path(path)
122
+ return removed
123
+
124
+
104
125
  # --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
105
126
 
106
127
  def known_sids(
@@ -141,6 +162,33 @@ def known_sids(
141
162
  return known
142
163
 
143
164
 
165
+ def _fill_liveness_inputs(
166
+ session_procs: list[SessionProc] | None,
167
+ agent_jobs: list[AgentJob] | None,
168
+ agents_map: dict[str, int | None] | None,
169
+ cur: set[int] | None,
170
+ ) -> tuple[list[SessionProc], list[AgentJob], dict[str, int | None], set[int]]:
171
+ """Fill any None input from `liveness.liveness_inputs()` (the ONE assembly).
172
+
173
+ Snapshot/view callers inject the shared world data (R11) and pay no IO
174
+ here; CLI / no-snapshot callers pass None and get the same assembly
175
+ `build_world_snapshot` uses (which swallows each source's errors → safe
176
+ empties). A caller that doesn't consume a source passes `[]`/`{}` — not
177
+ None — so an unused gap never triggers the fetch on its own.
178
+ """
179
+ if session_procs is None or agent_jobs is None or agents_map is None or cur is None:
180
+ d_procs, d_cur, d_jobs, d_agents = liveness.liveness_inputs()
181
+ if session_procs is None:
182
+ session_procs = d_procs
183
+ if agent_jobs is None:
184
+ agent_jobs = d_jobs
185
+ if agents_map is None:
186
+ agents_map = d_agents
187
+ if cur is None:
188
+ cur = d_cur
189
+ return session_procs, agent_jobs, agents_map, cur
190
+
191
+
144
192
  def _gather_known(
145
193
  sessions: list[Session],
146
194
  session_procs: list[SessionProc] | None = None,
@@ -148,26 +196,10 @@ def _gather_known(
148
196
  agents_map: dict[str, int | None] | None = None,
149
197
  cur: set[int] | None = None,
150
198
  ) -> set[str]:
151
- """Resolve the protected-sid set, self-fetching any omitted source.
152
-
153
- Snapshot/view callers inject the shared world data (R11); CLI / no-snapshot
154
- callers pass nothing and we read the (TTL-cached) registry + `alive_map`. Each
155
- self-read swallows its own errors → safe empties.
156
- """
157
- if session_procs is None:
158
- session_procs = liveness.live_session_procs()
159
- if agent_jobs is None:
160
- try:
161
- agent_jobs = registry.read_agent_jobs()
162
- except Exception:
163
- agent_jobs = []
164
- if agents_map is None:
165
- try:
166
- agents_map = liveness.alive_map()
167
- except Exception:
168
- agents_map = {}
169
- if cur is None:
170
- cur = proc.ancestor_pids()
199
+ """Resolve the protected-sid set, self-fetching any omitted source
200
+ (`_fill_liveness_inputs`)."""
201
+ session_procs, agent_jobs, agents_map, cur = _fill_liveness_inputs(
202
+ session_procs, agent_jobs, agents_map, cur)
171
203
  return known_sids(sessions, session_procs, agent_jobs, agents_map, cur)
172
204
 
173
205
 
@@ -399,18 +431,10 @@ def execute_session_removals(
399
431
  """
400
432
  if not proc.current_determinable():
401
433
  return 0
402
- # This self-fetch mirrors snapshot.liveness_inputs()'s assembly cleanup
403
- # sits BELOW snapshot in the data DAG and cannot import it; keep the two
404
- # in sync when the liveness sources change.
405
- if session_procs is None:
406
- session_procs = liveness.live_session_procs()
407
- if agents_map is None:
408
- try:
409
- agents_map = liveness.alive_map()
410
- except Exception:
411
- agents_map = {}
412
- if cur is None:
413
- cur = proc.ancestor_pids()
434
+ # agent_jobs is unused here: pass [] (not None) so an omitted-but-unused
435
+ # source never triggers the fetch on its own (see _fill_liveness_inputs).
436
+ session_procs, _, agents_map, cur = _fill_liveness_inputs(
437
+ session_procs, [], agents_map, cur)
414
438
  fresh_alive = {sp.sid for sp in session_procs if sp.proc_alive}
415
439
  fresh_alive |= {sid for sid, pid in agents_map.items() if pid}
416
440
  count = 0
@@ -470,39 +494,3 @@ def build_plan(
470
494
  zombie_pids=select_zombie_pids(session_procs, cur),
471
495
  aged_entries=list_aged_entries(now),
472
496
  )
473
-
474
-
475
- # --- Classified counts -----------------------------------------------------
476
-
477
- def cleanup_stats(sessions: list[Session]) -> dict[str, int]:
478
- """Summary counts for the Sessions cleanup submenu (view-facing contract).
479
-
480
- Keeps the established 4-key shape (`total/empty/short/orphans`) that the
481
- view reads; `orphans` is the sid-keyed orphan count (Strategy A).
482
- """
483
- return {
484
- "total": len(sessions),
485
- "empty": sum(1 for s in sessions if s.prompts == 0),
486
- "short": sum(1 for s in sessions if 0 < s.prompts <= 2),
487
- "orphans": len(list_orphan_dirs(sessions)),
488
- }
489
-
490
-
491
- def cleanup_classified(
492
- sessions: list[Session],
493
- session_procs: list[SessionProc],
494
- cur: set[int],
495
- agent_jobs: list[AgentJob] | None = None,
496
- agents_map: dict[str, int | None] | None = None,
497
- now: float | None = None,
498
- ) -> dict[str, int]:
499
- """Per-category cleanup counts (D6) — a thin view over `build_plan`.
500
-
501
- Deriving the counts FROM the plan keeps them equal to what a preview would
502
- show: `empty`/`short` count *prunable* sessions (not alive/current/recent),
503
- matching the preview list instead of the raw prompt tally.
504
- """
505
- return build_plan(
506
- sessions, session_procs, cur,
507
- agent_jobs=agent_jobs, agents_map=agents_map, now=now,
508
- ).counts()
@@ -20,6 +20,30 @@ _cache: dict[str, int | None] | None = None
20
20
  _cache_time: float = 0.0
21
21
 
22
22
 
23
+ def liveness_inputs() -> tuple[
24
+ list[SessionProc], set[int], list[AgentJob], dict[str, int | None]
25
+ ]:
26
+ """The shared liveness inputs — `(session_procs, cur, agent_jobs,
27
+ agents_map)` — fetched ONCE, jobs already host-enriched.
28
+
29
+ `build_world_snapshot`, the Sessions view's `fetch_pending(None)`
30
+ self-fetch, and `cleanup`'s protection-set assembly all consume this, so
31
+ the degraded/self-fetch paths are the same assembly instead of a
32
+ hand-kept mirror that can drift. Each read swallows its own errors → safe
33
+ empties.
34
+ """
35
+ session_procs = live_session_procs()
36
+ try:
37
+ agent_jobs = enrich_jobs(registry.read_agent_jobs(), session_procs)
38
+ except Exception:
39
+ agent_jobs = []
40
+ try:
41
+ agents_map = alive_map()
42
+ except Exception:
43
+ agents_map = {}
44
+ return session_procs, proc.ancestor_pids(), agent_jobs, agents_map
45
+
46
+
23
47
  def _scrub_dead_pids(
24
48
  mapping: dict[str, int | None],
25
49
  exists: Callable[[int | None], bool],