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.
- cc_session_control-0.4.0/LICENSE +21 -0
- cc_session_control-0.4.0/PKG-INFO +115 -0
- cc_session_control-0.4.0/README.md +92 -0
- cc_session_control-0.4.0/pyproject.toml +38 -0
- cc_session_control-0.4.0/setup.cfg +4 -0
- cc_session_control-0.4.0/src/cc_session_control/__init__.py +3 -0
- cc_session_control-0.4.0/src/cc_session_control/__main__.py +5 -0
- cc_session_control-0.4.0/src/cc_session_control/actions/__init__.py +0 -0
- cc_session_control-0.4.0/src/cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control-0.4.0/src/cc_session_control/actions/session_ops.py +150 -0
- cc_session_control-0.4.0/src/cc_session_control/app.py +264 -0
- cc_session_control-0.4.0/src/cc_session_control/cli.py +288 -0
- cc_session_control-0.4.0/src/cc_session_control/clipboard.py +44 -0
- cc_session_control-0.4.0/src/cc_session_control/config.py +132 -0
- cc_session_control-0.4.0/src/cc_session_control/data/__init__.py +0 -0
- cc_session_control-0.4.0/src/cc_session_control/data/agents.py +12 -0
- cc_session_control-0.4.0/src/cc_session_control/data/cleanup.py +402 -0
- cc_session_control-0.4.0/src/cc_session_control/data/environments.py +444 -0
- cc_session_control-0.4.0/src/cc_session_control/data/liveness.py +140 -0
- cc_session_control-0.4.0/src/cc_session_control/data/proc.py +214 -0
- cc_session_control-0.4.0/src/cc_session_control/data/rc.py +411 -0
- cc_session_control-0.4.0/src/cc_session_control/data/registry.py +155 -0
- cc_session_control-0.4.0/src/cc_session_control/data/sessions.py +188 -0
- cc_session_control-0.4.0/src/cc_session_control/data/snapshot.py +115 -0
- cc_session_control-0.4.0/src/cc_session_control/models.py +170 -0
- cc_session_control-0.4.0/src/cc_session_control/views/__init__.py +0 -0
- cc_session_control-0.4.0/src/cc_session_control/views/_session_row.py +145 -0
- cc_session_control-0.4.0/src/cc_session_control/views/agents.py +293 -0
- cc_session_control-0.4.0/src/cc_session_control/views/rc.py +374 -0
- cc_session_control-0.4.0/src/cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/PKG-INFO +115 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/SOURCES.txt +49 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/dependency_links.txt +1 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/entry_points.txt +2 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/requires.txt +4 -0
- cc_session_control-0.4.0/src/cc_session_control.egg-info/top_level.txt +1 -0
- cc_session_control-0.4.0/tests/test_agent_ops.py +226 -0
- cc_session_control-0.4.0/tests/test_agents.py +268 -0
- cc_session_control-0.4.0/tests/test_app.py +208 -0
- cc_session_control-0.4.0/tests/test_cleanup.py +352 -0
- cc_session_control-0.4.0/tests/test_cli.py +97 -0
- cc_session_control-0.4.0/tests/test_data.py +535 -0
- cc_session_control-0.4.0/tests/test_environments.py +413 -0
- cc_session_control-0.4.0/tests/test_liveness.py +143 -0
- cc_session_control-0.4.0/tests/test_proc.py +80 -0
- cc_session_control-0.4.0/tests/test_rc.py +203 -0
- cc_session_control-0.4.0/tests/test_registry.py +136 -0
- cc_session_control-0.4.0/tests/test_sessions.py +159 -0
- cc_session_control-0.4.0/tests/test_smoke.py +56 -0
- cc_session_control-0.4.0/tests/test_snapshot.py +96 -0
- 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"]
|
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Background-agent lifecycle actions (R4 / Phase 6).
|
|
2
|
+
|
|
3
|
+
The persistent truth for a background agent lives in `jobs/<short>/state.json`
|
|
4
|
+
(registry.read_agent_jobs → AgentJob); it carries NO pid. So a live worker's host
|
|
5
|
+
pid is resolved by JOINing the job's sid back to `sessions/<pid>.json`
|
|
6
|
+
(`job_host`) — a live worker with no sessions file is therefore unstoppable, a
|
|
7
|
+
documented orphan risk surfaced in `HELP`.
|
|
8
|
+
|
|
9
|
+
Capability red lines honoured here:
|
|
10
|
+
- respawn/takeover never replace the csctl process (respawn spawns a tmux
|
|
11
|
+
window; takeover hands a Session to the existing `do_resume` path run AFTER
|
|
12
|
+
the UI loop exits).
|
|
13
|
+
- stop only signals a confirmed-live joined host pid; killing a
|
|
14
|
+
`--remote-control`/bg worker does not always fully reap it (orphan risk).
|
|
15
|
+
- destructive ops (remove/stop) refuse when "current" can't be determined
|
|
16
|
+
(no `/proc`, R10) so they never blind-hit csctl's own session.
|
|
17
|
+
|
|
18
|
+
This is an action module: internals are English, but the user-facing label/help
|
|
19
|
+
constants the (Phase 7) background view reads are Simplified Chinese.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import signal
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import replace
|
|
29
|
+
|
|
30
|
+
from ..config import cfg
|
|
31
|
+
from ..data import cleanup, liveness, proc, rc, registry
|
|
32
|
+
from ..models import AgentJob, Session
|
|
33
|
+
|
|
34
|
+
# --- User-facing labels/help (Simplified Chinese, read by the Phase 7 view) ---
|
|
35
|
+
|
|
36
|
+
TAKEOVER_LABEL = "接回"
|
|
37
|
+
|
|
38
|
+
# Unified verb table (matches Sessions/RC): Enter/o=接回(primary), s=停止(kill a
|
|
39
|
+
# live thing), d=删除, R=重启(respawn). `r`=刷新 lives in the App-level footer
|
|
40
|
+
# prefix now, so it is NOT repeated here; separators are ` · ` like the other tabs.
|
|
41
|
+
KEYHINTS = "Enter/o 接回 · s 停止 · d 删除 · w 查看 · R 重启"
|
|
42
|
+
|
|
43
|
+
# Orphan-process risk (R4.5 red line): stop only kills the host pid joined from
|
|
44
|
+
# the sessions registry, killing a --remote-control/bg worker does not always
|
|
45
|
+
# fully reap it, and a live worker with no sessions file can't be located at all.
|
|
46
|
+
HELP = (
|
|
47
|
+
"后台 agent 生命周期:\n"
|
|
48
|
+
" Enter/o 接回(拉回前台,复用 resume;接运行中的 agent 会先确认接管) w 查看 timeline(只读)\n"
|
|
49
|
+
" R 重启(respawn) d 删除(仅已结束) s 停止(仅运行中,需确认) r 刷新\n"
|
|
50
|
+
"停止/孤儿风险:停止只能杀经 sessions 文件 join 到的 host pid;"
|
|
51
|
+
"杀 --remote-control/后台 agent 不一定彻底回收,可能残留孤儿进程,需手动确认;"
|
|
52
|
+
"找不到运行中的后台 agent 的 host pid 时无法停止。"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- host-pid join (shared by stop_job, remove_job, and the view) -------------
|
|
57
|
+
|
|
58
|
+
def job_host(job: AgentJob) -> tuple[int | None, bool]:
|
|
59
|
+
"""Resolve a background job's host pid + liveness — `(pid, alive)`.
|
|
60
|
+
|
|
61
|
+
`state.json` has no pid, so the worker's pid is JOINed from
|
|
62
|
+
`sessions/<pid>.json` on `job.sid` (a bg session proc; `kind` is typically
|
|
63
|
+
"bg"). Prefers a `/proc`-confirmed live match (so `alive=True` is trustworthy
|
|
64
|
+
and defeats pid reuse via `procStart`); falls back to the first sid match
|
|
65
|
+
with `alive=False`. Returns `(None, False)` when no sessions file exists for
|
|
66
|
+
the sid — that live worker is unstoppable (documented orphan risk).
|
|
67
|
+
|
|
68
|
+
Injects `/proc` liveness onto the registry rows, then defers to the single
|
|
69
|
+
pure join `registry.host_pid_for_sid` (shared with `snapshot._enrich_jobs`).
|
|
70
|
+
"""
|
|
71
|
+
procs = [
|
|
72
|
+
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
73
|
+
for sp in registry.read_session_procs()
|
|
74
|
+
]
|
|
75
|
+
return registry.host_pid_for_sid(job.sid, procs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- respawn ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def respawn_cmd(job: AgentJob) -> str:
|
|
81
|
+
"""The exact relaunch command: `claude --resume <resume_sid> <flags> --bg`.
|
|
82
|
+
|
|
83
|
+
Pure string build via `shlex.join` (split from `respawn` so it can be copied
|
|
84
|
+
to the clipboard / asserted in tests). `respawn_flags` are reused verbatim
|
|
85
|
+
from the recorded job state.
|
|
86
|
+
"""
|
|
87
|
+
args = ["claude", "--resume", job.resume_sid, *job.respawn_flags, "--bg"]
|
|
88
|
+
return shlex.join(args)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _job_window(job: AgentJob) -> str:
|
|
92
|
+
"""tmux window name for a respawned agent (name or short, suffixed)."""
|
|
93
|
+
base = (job.name or "bg").strip() or "bg"
|
|
94
|
+
return f"{base}-{job.short[:8]}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def respawn(job: AgentJob) -> str:
|
|
98
|
+
"""Relaunch a background agent in tmux; returns the exact command string.
|
|
99
|
+
|
|
100
|
+
Runs `respawn_cmd(job)` in the shared tmux session (`cfg.tmux_session`) so it
|
|
101
|
+
outlives the terminal — it does NOT os.exec/replace the csctl process. The
|
|
102
|
+
returned string also feeds the clipboard `y`-style key.
|
|
103
|
+
"""
|
|
104
|
+
cmd = respawn_cmd(job)
|
|
105
|
+
rc.run_in_tmux(cfg.tmux_session, _job_window(job), cmd)
|
|
106
|
+
return cmd
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- remove (settled agents only) ---------------------------------------------
|
|
110
|
+
|
|
111
|
+
def remove_job(job: AgentJob) -> bool:
|
|
112
|
+
"""Remove a SETTLED background agent: `jobs/<short>/` + its sid artifacts.
|
|
113
|
+
|
|
114
|
+
Returns True iff the job dir was removed. Refuses (False) for a LIVE worker
|
|
115
|
+
(`job_host` reports alive) and when "current" can't be determined (no
|
|
116
|
+
`/proc`, R10) — destructive, must not run blind.
|
|
117
|
+
"""
|
|
118
|
+
if not proc.current_determinable():
|
|
119
|
+
return False
|
|
120
|
+
_, alive = job_host(job)
|
|
121
|
+
if alive:
|
|
122
|
+
return False
|
|
123
|
+
job_dir = os.path.join(str(cfg.jobs_dir), job.short)
|
|
124
|
+
removed = cleanup._remove_path(job_dir)
|
|
125
|
+
# Reuse cleanup's artifact-path helper / remover: it returns the sid-keyed
|
|
126
|
+
# dirs (session-env/file-history/tasks/uploads) plus jobs/<sid[:8]> (which
|
|
127
|
+
# usually equals job_dir — a second remove is a harmless no-op), so the job's
|
|
128
|
+
# session leaves no orphan artifacts behind.
|
|
129
|
+
for path in cleanup._session_artifact_paths(job.sid):
|
|
130
|
+
cleanup._remove_path(path)
|
|
131
|
+
return removed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- watch (read-only) --------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def watch(job: AgentJob) -> str | None:
|
|
137
|
+
"""Path to the job's read-only `jobs/<short>/timeline.jsonl`, or None.
|
|
138
|
+
|
|
139
|
+
Pure lookup, no mutation — returns the path only when the file exists so the
|
|
140
|
+
view can fall back gracefully (R4.4 read-only watch).
|
|
141
|
+
"""
|
|
142
|
+
path = os.path.join(str(cfg.jobs_dir), job.short, "timeline.jsonl")
|
|
143
|
+
return path if os.path.isfile(path) else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- resume takeover (reuses the existing foreground resume path) -------------
|
|
147
|
+
|
|
148
|
+
def resume_takeover(job: AgentJob) -> Session:
|
|
149
|
+
"""Adapt a background job into a `Session` for the EXISTING resume path.
|
|
150
|
+
|
|
151
|
+
Bringing a bg session to the foreground is just a resume of its
|
|
152
|
+
`resume_sid`, so this returns a Session the view feeds to the SAME
|
|
153
|
+
`app.exit_with_resume` → `do_resume` pipeline used for foreground sessions —
|
|
154
|
+
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
155
|
+
`pid`/`alive` come from the host join so a live worker is killed first
|
|
156
|
+
(resume = takeover); `current` is computed so the launching session stays
|
|
157
|
+
protected. Does NOT itself replace the csctl process.
|
|
158
|
+
"""
|
|
159
|
+
pid, alive = job_host(job)
|
|
160
|
+
current = bool(pid) and pid in proc.ancestor_pids()
|
|
161
|
+
return Session(
|
|
162
|
+
sid=job.resume_sid,
|
|
163
|
+
cwd=job.cwd,
|
|
164
|
+
label=job.name or job.short,
|
|
165
|
+
mtime=0.0,
|
|
166
|
+
prompts=0,
|
|
167
|
+
pid=pid,
|
|
168
|
+
alive=alive,
|
|
169
|
+
current=current,
|
|
170
|
+
source="bg",
|
|
171
|
+
agent_short=job.short,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- stop (live workers only) -------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def stop_job(job: AgentJob) -> bool:
|
|
178
|
+
"""Stop a LIVE background worker via its joined host pid. True iff signalled.
|
|
179
|
+
|
|
180
|
+
The host pid is JOINed from `sessions/<pid>.json` (`job_host`); only a
|
|
181
|
+
confirmed-live pid is killed — a worker with no sessions file is unstoppable
|
|
182
|
+
(no-op False, orphan risk). Refuses when "current" can't be determined (no
|
|
183
|
+
`/proc`, R10). Owns the liveness-cache invalidation (like terminate). Killing
|
|
184
|
+
does not always fully reap a `--remote-control`/bg worker (orphan risk, see
|
|
185
|
+
`HELP`).
|
|
186
|
+
"""
|
|
187
|
+
if not proc.current_determinable():
|
|
188
|
+
return False
|
|
189
|
+
pid, alive = job_host(job)
|
|
190
|
+
if not alive or not pid:
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
os.kill(pid, signal.SIGTERM)
|
|
194
|
+
except ProcessLookupError:
|
|
195
|
+
liveness.invalidate_cache() # already gone — liveness changed
|
|
196
|
+
return True
|
|
197
|
+
except Exception:
|
|
198
|
+
return False
|
|
199
|
+
time.sleep(1)
|
|
200
|
+
liveness.invalidate_cache()
|
|
201
|
+
return True
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Session operations: resume, terminate, delete, clipboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .. import clipboard
|
|
11
|
+
from ..config import cfg
|
|
12
|
+
from ..data import proc, rc
|
|
13
|
+
from ..data.liveness import invalidate_cache
|
|
14
|
+
from ..models import Session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def terminate_session(s: Session) -> bool:
|
|
18
|
+
"""Send SIGTERM and own the liveness-cache invalidation.
|
|
19
|
+
|
|
20
|
+
Terminating is the one session op that changes `claude agents` liveness,
|
|
21
|
+
so it invalidates the alive_map cache itself — callers no longer have to
|
|
22
|
+
remember to. (delete/cleanup only touch already-dead sessions, so they
|
|
23
|
+
don't.)
|
|
24
|
+
|
|
25
|
+
R10: refuses (returns False) when "current" can't be determined (no
|
|
26
|
+
`/proc`) — we can't prove `s` is not the launching session, so a SIGTERM
|
|
27
|
+
here could hit csctl's own session.
|
|
28
|
+
"""
|
|
29
|
+
if not proc.current_determinable():
|
|
30
|
+
return False
|
|
31
|
+
if not s.pid:
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
35
|
+
except ProcessLookupError:
|
|
36
|
+
invalidate_cache() # already gone — liveness changed
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
time.sleep(1)
|
|
41
|
+
invalidate_cache()
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resume_plan(s: Session, fork: bool = False) -> tuple[str, list[str], bool]:
|
|
46
|
+
"""Shared resume recipe: the cwd to enter, the claude argv, and whether
|
|
47
|
+
to kill the old session first.
|
|
48
|
+
|
|
49
|
+
Returns (cwd, args, should_kill). Unified kill semantics: a fork is a copy
|
|
50
|
+
and leaves the original running, while a plain resume takes the session
|
|
51
|
+
over — so we kill only when it is alive, not the current session, and we
|
|
52
|
+
are NOT forking. `resume_cmd` and `do_resume` both obey this single
|
|
53
|
+
decision; they must not re-derive it.
|
|
54
|
+
"""
|
|
55
|
+
args = ["claude", "--resume", s.sid]
|
|
56
|
+
if fork:
|
|
57
|
+
args.append("--fork-session")
|
|
58
|
+
should_kill = s.alive and not s.current and not fork
|
|
59
|
+
return s.cwd, args, should_kill
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def would_take_over(s: Session, fork: bool = False) -> bool:
|
|
63
|
+
"""Whether resuming/relaunching `s` would first kill a live process (takeover).
|
|
64
|
+
|
|
65
|
+
The single source of the "needs confirmation" decision for the UI: it reads
|
|
66
|
+
`_resume_plan`'s `should_kill` so views never re-derive `s.alive and not
|
|
67
|
+
s.current` themselves (CLAUDE.md: should_kill is single-point — re-derivation
|
|
68
|
+
was the old divergence). `do_resume`/`relaunch_in_tmux` and the confirm gate
|
|
69
|
+
thus agree by construction.
|
|
70
|
+
"""
|
|
71
|
+
return _resume_plan(s, fork)[2]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resume_cmd(s: Session, fork: bool = False) -> str:
|
|
75
|
+
cwd, args, should_kill = _resume_plan(s, fork)
|
|
76
|
+
parts: list[str] = []
|
|
77
|
+
if should_kill and s.pid: # never emit a bare `kill None` (L7)
|
|
78
|
+
parts.append(f"kill {s.pid} && sleep 1")
|
|
79
|
+
if cwd:
|
|
80
|
+
parts.append(f"cd {shlex.quote(cwd)}")
|
|
81
|
+
parts.append(shlex.join(args))
|
|
82
|
+
return " && ".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def do_resume(s: Session, fork: bool = False) -> None:
|
|
86
|
+
"""chdir + (kill if needed) + exec claude. Does not return on success.
|
|
87
|
+
|
|
88
|
+
R10: when a takeover kill is required but "current" can't be determined (no
|
|
89
|
+
`/proc`), refuse — print a message and return WITHOUT killing or exec'ing, so
|
|
90
|
+
we never SIGTERM the launching session (every pid looks dead off `/proc`).
|
|
91
|
+
"""
|
|
92
|
+
cwd, args, should_kill = _resume_plan(s, fork)
|
|
93
|
+
if should_kill:
|
|
94
|
+
if not proc.current_determinable():
|
|
95
|
+
print(
|
|
96
|
+
"Refused: '/proc' unavailable — cannot determine the current "
|
|
97
|
+
"session, so the old process can't be safely killed (R10)."
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
time.sleep(1)
|
|
105
|
+
if cwd and os.path.isdir(cwd):
|
|
106
|
+
os.chdir(cwd)
|
|
107
|
+
os.execvp("claude", args)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rc_name(s: Session) -> str:
|
|
111
|
+
"""Remote-control label (shown in claude.ai/code) for a relaunched session."""
|
|
112
|
+
base = s.cwd.rstrip("/").rsplit("/", 1)[-1] if s.cwd else ""
|
|
113
|
+
return f"{base or 'session'}-{s.sid[:8]}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def tmux_resume_cmd(s: Session, fork: bool = False) -> str:
|
|
117
|
+
"""Shell command that resumes the session under remote control."""
|
|
118
|
+
cwd, args, _ = _resume_plan(s, fork)
|
|
119
|
+
args = args + ["--remote-control", _rc_name(s)]
|
|
120
|
+
line = shlex.join(args)
|
|
121
|
+
return f"cd {shlex.quote(cwd)} && {line}" if cwd else line
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def relaunch_in_tmux(s: Session, fork: bool = False) -> bool:
|
|
125
|
+
"""Relaunch a session as `claude --resume … --remote-control …` inside a
|
|
126
|
+
tmux window, so it outlives the terminal and is remotely controllable.
|
|
127
|
+
|
|
128
|
+
A live, non-current session is taken over (its old pid is killed first and
|
|
129
|
+
the liveness cache invalidated, like terminate); a fork leaves the original
|
|
130
|
+
running. csctl is NOT replaced — it just spawns the tmux window.
|
|
131
|
+
|
|
132
|
+
R10: when a takeover kill is required but "current" can't be determined (no
|
|
133
|
+
`/proc`), refuse (return False, do not kill or relaunch) — we can't prove `s`
|
|
134
|
+
is not the launching session.
|
|
135
|
+
"""
|
|
136
|
+
_, _, should_kill = _resume_plan(s, fork)
|
|
137
|
+
if should_kill and s.pid:
|
|
138
|
+
if not proc.current_determinable():
|
|
139
|
+
return False
|
|
140
|
+
try:
|
|
141
|
+
os.kill(s.pid, signal.SIGTERM)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
time.sleep(1)
|
|
145
|
+
invalidate_cache()
|
|
146
|
+
return rc.run_in_tmux(cfg.tmux_session, _rc_name(s), tmux_resume_cmd(s, fork))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_clipboard(text: str) -> bool:
|
|
150
|
+
return clipboard.copy(text)
|