cc-session-control 0.4.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 (51) hide show
  1. cc_session_control-0.4.0/LICENSE +21 -0
  2. cc_session_control-0.4.0/PKG-INFO +115 -0
  3. cc_session_control-0.4.0/README.md +92 -0
  4. cc_session_control-0.4.0/pyproject.toml +38 -0
  5. cc_session_control-0.4.0/setup.cfg +4 -0
  6. cc_session_control-0.4.0/src/cc_session_control/__init__.py +3 -0
  7. cc_session_control-0.4.0/src/cc_session_control/__main__.py +5 -0
  8. cc_session_control-0.4.0/src/cc_session_control/actions/__init__.py +0 -0
  9. cc_session_control-0.4.0/src/cc_session_control/actions/agent_ops.py +201 -0
  10. cc_session_control-0.4.0/src/cc_session_control/actions/session_ops.py +150 -0
  11. cc_session_control-0.4.0/src/cc_session_control/app.py +264 -0
  12. cc_session_control-0.4.0/src/cc_session_control/cli.py +288 -0
  13. cc_session_control-0.4.0/src/cc_session_control/clipboard.py +44 -0
  14. cc_session_control-0.4.0/src/cc_session_control/config.py +132 -0
  15. cc_session_control-0.4.0/src/cc_session_control/data/__init__.py +0 -0
  16. cc_session_control-0.4.0/src/cc_session_control/data/agents.py +12 -0
  17. cc_session_control-0.4.0/src/cc_session_control/data/cleanup.py +402 -0
  18. cc_session_control-0.4.0/src/cc_session_control/data/environments.py +444 -0
  19. cc_session_control-0.4.0/src/cc_session_control/data/liveness.py +140 -0
  20. cc_session_control-0.4.0/src/cc_session_control/data/proc.py +214 -0
  21. cc_session_control-0.4.0/src/cc_session_control/data/rc.py +411 -0
  22. cc_session_control-0.4.0/src/cc_session_control/data/registry.py +155 -0
  23. cc_session_control-0.4.0/src/cc_session_control/data/sessions.py +188 -0
  24. cc_session_control-0.4.0/src/cc_session_control/data/snapshot.py +115 -0
  25. cc_session_control-0.4.0/src/cc_session_control/models.py +170 -0
  26. cc_session_control-0.4.0/src/cc_session_control/views/__init__.py +0 -0
  27. cc_session_control-0.4.0/src/cc_session_control/views/_session_row.py +145 -0
  28. cc_session_control-0.4.0/src/cc_session_control/views/agents.py +293 -0
  29. cc_session_control-0.4.0/src/cc_session_control/views/rc.py +374 -0
  30. cc_session_control-0.4.0/src/cc_session_control/views/sessions.py +595 -0
  31. cc_session_control-0.4.0/src/cc_session_control.egg-info/PKG-INFO +115 -0
  32. cc_session_control-0.4.0/src/cc_session_control.egg-info/SOURCES.txt +49 -0
  33. cc_session_control-0.4.0/src/cc_session_control.egg-info/dependency_links.txt +1 -0
  34. cc_session_control-0.4.0/src/cc_session_control.egg-info/entry_points.txt +2 -0
  35. cc_session_control-0.4.0/src/cc_session_control.egg-info/requires.txt +4 -0
  36. cc_session_control-0.4.0/src/cc_session_control.egg-info/top_level.txt +1 -0
  37. cc_session_control-0.4.0/tests/test_agent_ops.py +226 -0
  38. cc_session_control-0.4.0/tests/test_agents.py +268 -0
  39. cc_session_control-0.4.0/tests/test_app.py +208 -0
  40. cc_session_control-0.4.0/tests/test_cleanup.py +352 -0
  41. cc_session_control-0.4.0/tests/test_cli.py +97 -0
  42. cc_session_control-0.4.0/tests/test_data.py +535 -0
  43. cc_session_control-0.4.0/tests/test_environments.py +413 -0
  44. cc_session_control-0.4.0/tests/test_liveness.py +143 -0
  45. cc_session_control-0.4.0/tests/test_proc.py +80 -0
  46. cc_session_control-0.4.0/tests/test_rc.py +203 -0
  47. cc_session_control-0.4.0/tests/test_registry.py +136 -0
  48. cc_session_control-0.4.0/tests/test_sessions.py +159 -0
  49. cc_session_control-0.4.0/tests/test_smoke.py +56 -0
  50. cc_session_control-0.4.0/tests/test_snapshot.py +96 -0
  51. cc_session_control-0.4.0/tests/test_views.py +945 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dzshzx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: cc-session-control
3
+ Version: 0.4.0
4
+ Summary: TUI manager for Claude Code sessions and Remote Control
5
+ Author: dzshzx
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dzshzx/cc-session-control
8
+ Project-URL: Repository, https://github.com/dzshzx/cc-session-control
9
+ Project-URL: Issues, https://github.com/dzshzx/cc-session-control/issues
10
+ Keywords: claude-code,session-manager,tui,remote-control,urwid
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: User Interfaces
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: urwid>=2.0.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # cc-session-control
25
+
26
+ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Control.
27
+
28
+ **CLI command: `csctl`**
29
+
30
+ ## Features
31
+
32
+ - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
33
+ - **Remote Control Tab** — Start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
34
+ - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
35
+
36
+ Built with [urwid](https://urwid.org/).
37
+
38
+ > **UI language:** Simplified Chinese (notifications and status text). CLI output is in English.
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.12+
43
+ - [Claude Code](https://claude.ai/code) CLI installed and authenticated
44
+ - tmux (for Remote Control management)
45
+ - Linux / WSL (macOS support is partial — `/proc`-based liveness detection is Linux-only)
46
+
47
+ ## Installation
48
+
49
+ > **Coming soon to PyPI.** The package is not published yet. Until the first
50
+ > release lands, install from GitHub (see *Latest `master` build* below). Once
51
+ > published, the commands below will work.
52
+
53
+ Install the latest published release:
54
+
55
+ ```bash
56
+ uv tool install cc-session-control
57
+ # or
58
+ pipx install cc-session-control
59
+ ```
60
+
61
+ Upgrade later with `uv tool upgrade cc-session-control` (or `pipx upgrade
62
+ cc-session-control`).
63
+
64
+ ### Latest `master` build
65
+
66
+ Until the package is on PyPI — or to try the newest `master` before it is
67
+ released — install from GitHub:
68
+
69
+ ```bash
70
+ uv tool install --reinstall git+https://github.com/dzshzx/cc-session-control.git
71
+ ```
72
+
73
+ `csctl` manages the Claude Code state on the machine where it is installed: the
74
+ local `~/.claude`, local `tmux`, and local workspace. Install it separately on
75
+ each machine whose sessions you want to manage. For working *on* the code
76
+ instead of using it, see [CONTRIBUTING.md](CONTRIBUTING.md).
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ # Open TUI
82
+ csctl
83
+
84
+ # Remote Control management (no TUI)
85
+ csctl rc status # Show all projects and RC status
86
+ csctl rc add . # Add current project to RC list and start
87
+ csctl rc add myproject # Add by name
88
+ csctl rc rm myproject # Remove and stop
89
+ csctl rc up # Start all listed projects
90
+ csctl rc stop all # Stop all RC servers
91
+ csctl rc list # Show auto-start list
92
+
93
+ # Session cleanup
94
+ csctl prune # Dry run: show stats
95
+ csctl prune --max-prompts 1 --apply # Delete sessions with ≤1 prompt
96
+
97
+ # Options
98
+ csctl --workspace ~/projects # Override workspace root
99
+ csctl --version
100
+ ```
101
+
102
+ ## Configuration
103
+
104
+ | Environment Variable | Default | Description |
105
+ |---|---|---|
106
+ | `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
107
+ | `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
108
+ | `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
109
+ | `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
110
+
111
+ RC auto-start list is stored at `$XDG_CONFIG_HOME/csctl/rc-enabled`.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,92 @@
1
+ # cc-session-control
2
+
3
+ TUI manager for [Claude Code](https://claude.ai/code) sessions and Remote Control.
4
+
5
+ **CLI command: `csctl`**
6
+
7
+ ## Features
8
+
9
+ - **Sessions Tab** — View, resume, terminate, and delete Claude Code sessions across all projects
10
+ - **Remote Control Tab** — Start/stop RC servers per project, toggle auto-start, show running/stopped/dead states
11
+ - **Cleanup Tab** — Prune empty/short sessions, sweep orphan artifact directories
12
+
13
+ Built with [urwid](https://urwid.org/).
14
+
15
+ > **UI language:** Simplified Chinese (notifications and status text). CLI output is in English.
16
+
17
+ ## Requirements
18
+
19
+ - Python 3.12+
20
+ - [Claude Code](https://claude.ai/code) CLI installed and authenticated
21
+ - tmux (for Remote Control management)
22
+ - Linux / WSL (macOS support is partial — `/proc`-based liveness detection is Linux-only)
23
+
24
+ ## Installation
25
+
26
+ > **Coming soon to PyPI.** The package is not published yet. Until the first
27
+ > release lands, install from GitHub (see *Latest `master` build* below). Once
28
+ > published, the commands below will work.
29
+
30
+ Install the latest published release:
31
+
32
+ ```bash
33
+ uv tool install cc-session-control
34
+ # or
35
+ pipx install cc-session-control
36
+ ```
37
+
38
+ Upgrade later with `uv tool upgrade cc-session-control` (or `pipx upgrade
39
+ cc-session-control`).
40
+
41
+ ### Latest `master` build
42
+
43
+ Until the package is on PyPI — or to try the newest `master` before it is
44
+ released — install from GitHub:
45
+
46
+ ```bash
47
+ uv tool install --reinstall git+https://github.com/dzshzx/cc-session-control.git
48
+ ```
49
+
50
+ `csctl` manages the Claude Code state on the machine where it is installed: the
51
+ local `~/.claude`, local `tmux`, and local workspace. Install it separately on
52
+ each machine whose sessions you want to manage. For working *on* the code
53
+ instead of using it, see [CONTRIBUTING.md](CONTRIBUTING.md).
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # Open TUI
59
+ csctl
60
+
61
+ # Remote Control management (no TUI)
62
+ csctl rc status # Show all projects and RC status
63
+ csctl rc add . # Add current project to RC list and start
64
+ csctl rc add myproject # Add by name
65
+ csctl rc rm myproject # Remove and stop
66
+ csctl rc up # Start all listed projects
67
+ csctl rc stop all # Stop all RC servers
68
+ csctl rc list # Show auto-start list
69
+
70
+ # Session cleanup
71
+ csctl prune # Dry run: show stats
72
+ csctl prune --max-prompts 1 --apply # Delete sessions with ≤1 prompt
73
+
74
+ # Options
75
+ csctl --workspace ~/projects # Override workspace root
76
+ csctl --version
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ | Environment Variable | Default | Description |
82
+ |---|---|---|
83
+ | `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
84
+ | `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
85
+ | `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
86
+ | `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
87
+
88
+ RC auto-start list is stored at `$XDG_CONFIG_HOME/csctl/rc-enabled`.
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cc-session-control"
7
+ dynamic = ["version"]
8
+ description = "TUI manager for Claude Code sessions and Remote Control"
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ authors = [{ name = "dzshzx" }]
13
+ keywords = ["claude-code", "session-manager", "tui", "remote-control", "urwid"]
14
+ dependencies = ["urwid>=2.0.0"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: User Interfaces",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/dzshzx/cc-session-control"
25
+ Repository = "https://github.com/dzshzx/cc-session-control"
26
+ Issues = "https://github.com/dzshzx/cc-session-control/issues"
27
+
28
+ [project.scripts]
29
+ csctl = "cc_session_control.cli:main"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=7.0"]
33
+
34
+ [tool.setuptools.dynamic]
35
+ version = { attr = "cc_session_control.__version__" }
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()
@@ -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)