cc-session-control 0.7.2__tar.gz → 0.7.3__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.7.2/src/cc_session_control.egg-info → cc_session_control-0.7.3}/PKG-INFO +7 -8
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/README.md +6 -7
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/cli.py +26 -29
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/config.py +0 -37
- cc_session_control-0.7.3/src/cc_session_control/data/rc.py +429 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/tmux.py +43 -25
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/models.py +27 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_base.py +16 -3
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/rc.py +15 -9
- {cc_session_control-0.7.2 → cc_session_control-0.7.3/src/cc_session_control.egg-info}/PKG-INFO +7 -8
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_data.py +58 -26
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_rc.py +72 -48
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_smoke.py +0 -7
- cc_session_control-0.7.3/tests/test_trust.py +181 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_views.py +22 -3
- cc_session_control-0.7.2/src/cc_session_control/data/rc.py +0 -324
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/LICENSE +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/pyproject.toml +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/setup.cfg +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/__main__.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/agent_ops.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/resume_list.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/session_ops.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/skill_ops.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/app.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/clipboard.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/cleanup.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/environments.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/liveness.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/proc.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/registry.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/sessions.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/snapshot.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/skill/SKILL.md +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/theme.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_colspec.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_confirm.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_keytable.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_rows.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_session_row.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/agents.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/sessions.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/requires.txt +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/top_level.txt +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_agent_ops.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_agents.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_app.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_cleanup.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_cli.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_environments.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_liveness.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_proc.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_registry.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_resume_list.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_sessions.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_skill_ops.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_snapshot.py +0 -0
- {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_theme.py +0 -0
{cc_session_control-0.7.2/src/cc_session_control.egg-info → cc_session_control-0.7.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cc-session-control
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: TUI manager for Claude Code sessions and Remote Control
|
|
5
5
|
Author: dzshzx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,8 +66,9 @@ uv tool install --reinstall git+https://github.com/dzshzx/cc-session-control.git
|
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`csctl` manages the Claude Code state on the machine where it is installed: the
|
|
69
|
-
local `~/.claude`, local `tmux`, and
|
|
70
|
-
each machine whose sessions you want
|
|
69
|
+
local `~/.claude`, local `tmux`, and the projects recorded in the local
|
|
70
|
+
`~/.claude.json`. Install it separately on each machine whose sessions you want
|
|
71
|
+
to manage. For working *on* the code
|
|
71
72
|
instead of using it, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
72
73
|
|
|
73
74
|
## Usage
|
|
@@ -78,9 +79,9 @@ csctl
|
|
|
78
79
|
|
|
79
80
|
# Remote Control management (no TUI)
|
|
80
81
|
csctl rc status # Show all projects and RC status
|
|
81
|
-
csctl rc add . # Add current
|
|
82
|
-
csctl rc add
|
|
83
|
-
csctl rc rm
|
|
82
|
+
csctl rc add . # Add current directory to RC list and start
|
|
83
|
+
csctl rc add ~/code/app # Add by directory path
|
|
84
|
+
csctl rc rm ~/code/app # Remove and stop
|
|
84
85
|
csctl rc up # Start all listed projects
|
|
85
86
|
csctl rc stop all # Stop all RC servers
|
|
86
87
|
csctl rc list # Show auto-start list
|
|
@@ -103,7 +104,6 @@ csctl skill install --force # Replace an existing skill directory
|
|
|
103
104
|
csctl skill uninstall
|
|
104
105
|
|
|
105
106
|
# Options
|
|
106
|
-
csctl --workspace ~/projects # Override workspace root
|
|
107
107
|
csctl --theme light # Force the TUI palette (auto/dark/light)
|
|
108
108
|
csctl --version
|
|
109
109
|
```
|
|
@@ -112,7 +112,6 @@ csctl --version
|
|
|
112
112
|
|
|
113
113
|
| Environment Variable | Default | Description |
|
|
114
114
|
|---|---|---|
|
|
115
|
-
| `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
|
|
116
115
|
| `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
|
|
117
116
|
| `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
|
|
118
117
|
| `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
|
|
@@ -43,8 +43,9 @@ uv tool install --reinstall git+https://github.com/dzshzx/cc-session-control.git
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
`csctl` manages the Claude Code state on the machine where it is installed: the
|
|
46
|
-
local `~/.claude`, local `tmux`, and
|
|
47
|
-
each machine whose sessions you want
|
|
46
|
+
local `~/.claude`, local `tmux`, and the projects recorded in the local
|
|
47
|
+
`~/.claude.json`. Install it separately on each machine whose sessions you want
|
|
48
|
+
to manage. For working *on* the code
|
|
48
49
|
instead of using it, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
49
50
|
|
|
50
51
|
## Usage
|
|
@@ -55,9 +56,9 @@ csctl
|
|
|
55
56
|
|
|
56
57
|
# Remote Control management (no TUI)
|
|
57
58
|
csctl rc status # Show all projects and RC status
|
|
58
|
-
csctl rc add . # Add current
|
|
59
|
-
csctl rc add
|
|
60
|
-
csctl rc rm
|
|
59
|
+
csctl rc add . # Add current directory to RC list and start
|
|
60
|
+
csctl rc add ~/code/app # Add by directory path
|
|
61
|
+
csctl rc rm ~/code/app # Remove and stop
|
|
61
62
|
csctl rc up # Start all listed projects
|
|
62
63
|
csctl rc stop all # Stop all RC servers
|
|
63
64
|
csctl rc list # Show auto-start list
|
|
@@ -80,7 +81,6 @@ csctl skill install --force # Replace an existing skill directory
|
|
|
80
81
|
csctl skill uninstall
|
|
81
82
|
|
|
82
83
|
# Options
|
|
83
|
-
csctl --workspace ~/projects # Override workspace root
|
|
84
84
|
csctl --theme light # Force the TUI palette (auto/dark/light)
|
|
85
85
|
csctl --version
|
|
86
86
|
```
|
|
@@ -89,7 +89,6 @@ csctl --version
|
|
|
89
89
|
|
|
90
90
|
| Environment Variable | Default | Description |
|
|
91
91
|
|---|---|---|
|
|
92
|
-
| `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
|
|
93
92
|
| `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
|
|
94
93
|
| `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
|
|
95
94
|
| `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -16,7 +15,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
16
15
|
description="TUI manager for Claude Code sessions and Remote Control",
|
|
17
16
|
)
|
|
18
17
|
parser.add_argument("--version", action="version", version=f"csctl {__version__}")
|
|
19
|
-
parser.add_argument("--workspace", type=Path, help="Override workspace root directory")
|
|
20
18
|
parser.add_argument(
|
|
21
19
|
"--theme", choices=("auto", "dark", "light"),
|
|
22
20
|
help="TUI palette (default: auto-detect the terminal background; env CSCTL_THEME)",
|
|
@@ -29,12 +27,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
29
27
|
rc_sub = rc_parser.add_subparsers(dest="rc_command")
|
|
30
28
|
rc_sub.add_parser("status", help="Show RC status for all projects")
|
|
31
29
|
rc_add = rc_sub.add_parser("add", help="Add project to RC list and start")
|
|
32
|
-
rc_add.add_argument("project", nargs="?", default=".", help="Project
|
|
30
|
+
rc_add.add_argument("project", nargs="?", default=".", help="Project directory (default: current dir)")
|
|
33
31
|
rc_rm = rc_sub.add_parser("rm", help="Remove project from RC list and stop")
|
|
34
|
-
rc_rm.add_argument("project", help="Project
|
|
32
|
+
rc_rm.add_argument("project", help="Project directory")
|
|
35
33
|
rc_sub.add_parser("up", help="Start all listed projects")
|
|
36
34
|
rc_stop = rc_sub.add_parser("stop", help="Stop RC for a project")
|
|
37
|
-
rc_stop.add_argument("target", help="Project
|
|
35
|
+
rc_stop.add_argument("target", help="Project directory or 'all'")
|
|
38
36
|
rc_sub.add_parser("list", help="Show enabled project list")
|
|
39
37
|
|
|
40
38
|
# prune subcommand
|
|
@@ -73,8 +71,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
73
71
|
|
|
74
72
|
def _apply_global_flags(args: argparse.Namespace) -> None:
|
|
75
73
|
from .config import cfg
|
|
76
|
-
if args.workspace:
|
|
77
|
-
cfg.workspace = args.workspace
|
|
78
74
|
if args.theme:
|
|
79
75
|
cfg.theme = args.theme
|
|
80
76
|
|
|
@@ -89,36 +85,36 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
89
85
|
sub = args.rc_command
|
|
90
86
|
|
|
91
87
|
if sub == "status":
|
|
92
|
-
|
|
88
|
+
from .data.sessions import scan as scan_sessions
|
|
89
|
+
|
|
90
|
+
# Same ordering as the 项目 tab (rc.order_by_activity — single
|
|
91
|
+
# source); costs one transcript scan, like `csctl resume`.
|
|
92
|
+
projects = rc.order_by_activity(rc.scan(), scan_sessions())
|
|
93
93
|
for p in projects:
|
|
94
94
|
icon = {"running": "[running]", "dead": "[dead ]", "stopped": "[stopped]"}.get(p.status, p.status)
|
|
95
95
|
auto = "auto" if p.auto_start else " "
|
|
96
96
|
missing = "" if p.dir_exists else " (directory missing)"
|
|
97
|
-
print(f" {icon} {auto} {p.name}{missing}")
|
|
97
|
+
print(f" {icon} {auto} {p.name} {p.directory}{missing}")
|
|
98
98
|
|
|
99
99
|
elif sub == "add":
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
else:
|
|
107
|
-
print(f"Current directory is not under {ws}. Specify project name explicitly.")
|
|
108
|
-
sys.exit(1)
|
|
109
|
-
if not rc.is_trusted(proj):
|
|
110
|
-
print(f"Not trusted: {proj} — run 'claude' in that directory first to accept the trust dialog")
|
|
100
|
+
path = os.path.abspath(args.project)
|
|
101
|
+
if not os.path.isdir(path):
|
|
102
|
+
print(f"No such directory: {path}")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
if not rc.is_trusted(path):
|
|
105
|
+
print(f"Not trusted: {path} — run 'claude' in that directory first to accept the trust dialog")
|
|
111
106
|
sys.exit(1)
|
|
112
|
-
rc.list_add(
|
|
113
|
-
print(f"Added to list: {
|
|
114
|
-
ok = rc.start_one(
|
|
107
|
+
rc.list_add(path)
|
|
108
|
+
print(f"Added to list: {path}")
|
|
109
|
+
ok = rc.start_one(path)
|
|
115
110
|
if ok:
|
|
116
|
-
print(f"Started
|
|
111
|
+
print(f"Started RC server for {path}")
|
|
117
112
|
|
|
118
113
|
elif sub == "rm":
|
|
119
|
-
|
|
120
|
-
rc.
|
|
121
|
-
|
|
114
|
+
path = os.path.abspath(args.project)
|
|
115
|
+
rc.list_rm(path)
|
|
116
|
+
rc.stop_one(path)
|
|
117
|
+
print(f"Removed and stopped: {path}")
|
|
122
118
|
|
|
123
119
|
elif sub == "up":
|
|
124
120
|
enabled = rc.list_enabled()
|
|
@@ -133,8 +129,9 @@ def _cmd_rc(args: argparse.Namespace) -> None:
|
|
|
133
129
|
rc.stop_all()
|
|
134
130
|
print("Stopped all")
|
|
135
131
|
else:
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
path = os.path.abspath(args.target)
|
|
133
|
+
ok = rc.stop_one(path)
|
|
134
|
+
print(f"Stopped {path}" if ok else f"Not running: {path}")
|
|
138
135
|
|
|
139
136
|
elif sub == "list":
|
|
140
137
|
for name in rc.list_enabled():
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import os
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
@@ -20,18 +19,6 @@ class Config:
|
|
|
20
19
|
self.cleanup_age_days: int = int(os.environ.get("CSCTL_CLEANUP_AGE_DAYS", "14"))
|
|
21
20
|
# TUI palette: "auto" (detect the terminal background) | "dark" | "light".
|
|
22
21
|
self.theme: str = os.environ.get("CSCTL_THEME", "auto")
|
|
23
|
-
self._workspace: Path | None = None
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def workspace(self) -> Path:
|
|
27
|
-
if self._workspace is not None:
|
|
28
|
-
return self._workspace
|
|
29
|
-
self._workspace = _detect_workspace(self.claude_json)
|
|
30
|
-
return self._workspace
|
|
31
|
-
|
|
32
|
-
@workspace.setter
|
|
33
|
-
def workspace(self, value: Path) -> None:
|
|
34
|
-
self._workspace = value
|
|
35
22
|
|
|
36
23
|
@property
|
|
37
24
|
def projects_root(self) -> Path:
|
|
@@ -108,28 +95,4 @@ class Config:
|
|
|
108
95
|
return self.claude_home / "skills"
|
|
109
96
|
|
|
110
97
|
|
|
111
|
-
def _detect_workspace(claude_json: Path) -> Path:
|
|
112
|
-
env = os.environ.get("CSCTL_WORKSPACE")
|
|
113
|
-
if env:
|
|
114
|
-
return Path(env)
|
|
115
|
-
|
|
116
|
-
default = Path.home() / "workspace"
|
|
117
|
-
if default.is_dir():
|
|
118
|
-
return default
|
|
119
|
-
|
|
120
|
-
try:
|
|
121
|
-
with open(claude_json) as f:
|
|
122
|
-
data = json.load(f)
|
|
123
|
-
dirs = [k for k in data.get("projects", {}) if "/" in k]
|
|
124
|
-
if dirs:
|
|
125
|
-
from os.path import commonpath
|
|
126
|
-
common = Path(commonpath(dirs))
|
|
127
|
-
if common.is_dir() and common != Path.home():
|
|
128
|
-
return common
|
|
129
|
-
except Exception:
|
|
130
|
-
pass
|
|
131
|
-
|
|
132
|
-
return Path.cwd()
|
|
133
|
-
|
|
134
|
-
|
|
135
98
|
cfg = Config()
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""RC project management — manage Claude Code Remote Control via tmux.
|
|
2
|
+
|
|
3
|
+
Projects are keyed by ABSOLUTE DIRECTORY PATH (no workspace-root concept):
|
|
4
|
+
membership = a `~/.claude.json` projects entry that is effectively trusted
|
|
5
|
+
(`models.effective_trust` — claude's own ancestor-inheriting dialog gate).
|
|
6
|
+
`RCProject.name` is a derived basename for display only; every join
|
|
7
|
+
(rc-enabled list, tmux windows via `@csctl_path`, claude.json lookups) uses
|
|
8
|
+
the path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from ..config import cfg
|
|
20
|
+
from ..models import (
|
|
21
|
+
EnvRecord,
|
|
22
|
+
RCProject,
|
|
23
|
+
RCServer,
|
|
24
|
+
Session,
|
|
25
|
+
effective_trust,
|
|
26
|
+
split_env_id,
|
|
27
|
+
)
|
|
28
|
+
from . import environments, proc, tmux
|
|
29
|
+
|
|
30
|
+
# Cloud bridge env id printed to a managed server's pane (`environment=env_…`).
|
|
31
|
+
_ENV_ID_RE = re.compile(r"env_[A-Za-z0-9]+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ensure_list() -> None:
|
|
35
|
+
os.makedirs(cfg.config_dir, exist_ok=True)
|
|
36
|
+
if not cfg.rc_list.is_file():
|
|
37
|
+
cfg.rc_list.touch()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _legacy_workspace_root() -> str:
|
|
41
|
+
"""FROZEN pre-0.7.3 workspace detection — rc-enabled migration ONLY.
|
|
42
|
+
|
|
43
|
+
Replicates the deleted `config._detect_workspace` order (CSCTL_WORKSPACE
|
|
44
|
+
override → `~/workspace` → commonpath of claude.json keys → cwd) so legacy
|
|
45
|
+
short-name lines resolve exactly as the old csctl resolved them. Dead by
|
|
46
|
+
design once a machine's list has been rewritten; never reuse elsewhere.
|
|
47
|
+
"""
|
|
48
|
+
env = os.environ.get("CSCTL_WORKSPACE")
|
|
49
|
+
if env:
|
|
50
|
+
return env
|
|
51
|
+
default = os.path.join(os.path.expanduser("~"), "workspace")
|
|
52
|
+
if os.path.isdir(default):
|
|
53
|
+
return default
|
|
54
|
+
try:
|
|
55
|
+
dirs = [k for k in _load_projects() if "/" in k]
|
|
56
|
+
if dirs:
|
|
57
|
+
common = os.path.commonpath(dirs)
|
|
58
|
+
if os.path.isdir(common) and common != os.path.expanduser("~"):
|
|
59
|
+
return common
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return os.getcwd()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _migrate_lines(lines: list[str]) -> tuple[list[str], bool]:
|
|
66
|
+
"""Absolute-path-ify legacy rc-enabled lines.
|
|
67
|
+
|
|
68
|
+
Comments/blank lines pass through verbatim; absolute paths are kept;
|
|
69
|
+
anything else (pre-0.7.3 short names, including relative `a/b` forms)
|
|
70
|
+
resolves against the frozen legacy workspace root. Idempotent — a fully
|
|
71
|
+
migrated file reports changed=False, so no rewrite is triggered.
|
|
72
|
+
"""
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
changed = False
|
|
75
|
+
root: str | None = None
|
|
76
|
+
for raw in lines:
|
|
77
|
+
s = raw.strip()
|
|
78
|
+
if not s or s.startswith("#") or s.startswith("/"):
|
|
79
|
+
out.append(raw)
|
|
80
|
+
continue
|
|
81
|
+
if root is None:
|
|
82
|
+
root = _legacy_workspace_root()
|
|
83
|
+
out.append(os.path.join(root, s))
|
|
84
|
+
changed = True
|
|
85
|
+
return out, changed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_list(lines: list[str]) -> None:
|
|
89
|
+
"""Atomic rc-enabled rewrite (unique tmp + rename) — a concurrent reader
|
|
90
|
+
never sees a truncated file and concurrent writers cannot interleave."""
|
|
91
|
+
_ensure_list()
|
|
92
|
+
tmp = cfg.rc_list.parent / f".{cfg.rc_list.name}.{os.getpid()}.tmp"
|
|
93
|
+
tmp.write_text("".join(f"{line}\n" for line in lines))
|
|
94
|
+
os.replace(tmp, cfg.rc_list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def list_enabled() -> list[str]:
|
|
98
|
+
_ensure_list()
|
|
99
|
+
try:
|
|
100
|
+
raw = cfg.rc_list.read_text().splitlines()
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return []
|
|
103
|
+
migrated, changed = _migrate_lines(raw)
|
|
104
|
+
if changed:
|
|
105
|
+
_write_list(migrated)
|
|
106
|
+
raw = migrated
|
|
107
|
+
return [
|
|
108
|
+
line.strip() for line in raw
|
|
109
|
+
if line.strip() and not line.strip().startswith("#")
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def list_has(path: str) -> bool:
|
|
114
|
+
return path in list_enabled()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_add(path: str) -> None:
|
|
118
|
+
_ensure_list()
|
|
119
|
+
if list_has(path):
|
|
120
|
+
return
|
|
121
|
+
with open(cfg.rc_list, "a") as f:
|
|
122
|
+
f.write(f"{path}\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def list_rm(path: str) -> None:
|
|
126
|
+
try:
|
|
127
|
+
lines = cfg.rc_list.read_text().splitlines()
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return
|
|
130
|
+
_write_list([line for line in lines if line.strip() != path])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def toggle_autostart(path: str) -> bool:
|
|
134
|
+
"""Toggle project in the autostart list. Returns new state."""
|
|
135
|
+
if list_has(path):
|
|
136
|
+
list_rm(path)
|
|
137
|
+
return False
|
|
138
|
+
list_add(path)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_projects() -> dict:
|
|
143
|
+
"""Read the `projects` map from ~/.claude.json, or {} on any failure.
|
|
144
|
+
|
|
145
|
+
Single source for the claude.json read shared by trusted_projects /
|
|
146
|
+
is_trusted, so the open+parse+swallow dance lives in one place.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
with open(cfg.claude_json) as f:
|
|
150
|
+
return json.load(f).get("projects", {}) or {}
|
|
151
|
+
except Exception:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _trusted_in(projects: dict) -> set[str]:
|
|
156
|
+
"""Effectively-trusted absolute-path keys of a claude.json projects map."""
|
|
157
|
+
return {
|
|
158
|
+
key for key in projects
|
|
159
|
+
if isinstance(key, str) and key.startswith("/")
|
|
160
|
+
and effective_trust(key, projects)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def trusted_projects() -> list[str]:
|
|
165
|
+
"""Absolute paths of every effectively-trusted claude.json project entry.
|
|
166
|
+
|
|
167
|
+
Membership base of the 项目 tab. Directory existence and residue handling
|
|
168
|
+
stay in `scan()` (unchanged split)."""
|
|
169
|
+
return sorted(_trusted_in(_load_projects()))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_trusted(path: str) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
return effective_trust(path, _load_projects())
|
|
175
|
+
except Exception:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _basename(path: str) -> str:
|
|
180
|
+
"""Display name derived from the path — NEVER an identity key."""
|
|
181
|
+
return os.path.basename(path.rstrip("/")) or path
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- RC-scoped thin delegates over data/tmux.py ---------------------------
|
|
185
|
+
# Bound to `cfg.rc_session`. The generic tmux adapter lives in `data/tmux.py`
|
|
186
|
+
# (the single seam — only its `_tmux_run` touches `subprocess`); these stay
|
|
187
|
+
# here (rather than inlined at call sites) because `scan`/`scan_servers` and
|
|
188
|
+
# their tests poke these exact RC-scoped names.
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tmux_windows() -> list[tmux.TmuxWindow]:
|
|
192
|
+
return tmux.list_windows_meta(cfg.rc_session)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _tmux_capture_pane(target: str) -> str:
|
|
196
|
+
return tmux.capture_pane(target)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _window_for(path: str) -> tmux.TmuxWindow | None:
|
|
200
|
+
"""The managed window belonging to `path`, or None (normpath equality on
|
|
201
|
+
the window's declared/adopted `path` metadata — never by window name)."""
|
|
202
|
+
norm = os.path.normpath(path)
|
|
203
|
+
for w in _tmux_windows():
|
|
204
|
+
if w.path and os.path.normpath(w.path) == norm:
|
|
205
|
+
return w
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_rc_at_startup(directory: str) -> bool | None:
|
|
210
|
+
for name in ("settings.local.json", "settings.json"):
|
|
211
|
+
path = os.path.join(directory, ".claude", name)
|
|
212
|
+
try:
|
|
213
|
+
with open(path) as f:
|
|
214
|
+
val = json.load(f).get("remoteControlAtStartup")
|
|
215
|
+
if val is not None:
|
|
216
|
+
return bool(val)
|
|
217
|
+
except Exception:
|
|
218
|
+
continue
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def set_rc_at_startup(directory: str, value: bool | None) -> None:
|
|
223
|
+
settings_dir = os.path.join(directory, ".claude")
|
|
224
|
+
path = os.path.join(settings_dir, "settings.local.json")
|
|
225
|
+
os.makedirs(settings_dir, exist_ok=True)
|
|
226
|
+
try:
|
|
227
|
+
with open(path) as f:
|
|
228
|
+
data = json.load(f)
|
|
229
|
+
except Exception:
|
|
230
|
+
data = {}
|
|
231
|
+
if value is None:
|
|
232
|
+
data.pop("remoteControlAtStartup", None)
|
|
233
|
+
else:
|
|
234
|
+
data["remoteControlAtStartup"] = value
|
|
235
|
+
with open(path, "w") as f:
|
|
236
|
+
json.dump(data, f, indent=2)
|
|
237
|
+
f.write("\n")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def scan() -> list[RCProject]:
|
|
241
|
+
# ONE claude.json load feeds membership, trust flags and spawn modes —
|
|
242
|
+
# no per-project re-parse.
|
|
243
|
+
projects_map = _load_projects()
|
|
244
|
+
trusted = _trusted_in(projects_map)
|
|
245
|
+
enabled = set(list_enabled())
|
|
246
|
+
by_path = {
|
|
247
|
+
os.path.normpath(w.path): w for w in _tmux_windows() if w.path
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
result: list[RCProject] = []
|
|
251
|
+
for path in sorted(trusted | enabled):
|
|
252
|
+
win = by_path.get(os.path.normpath(path))
|
|
253
|
+
dir_exists = os.path.isdir(path)
|
|
254
|
+
if not dir_exists and path not in enabled and win is None:
|
|
255
|
+
# Pure trust residue: the directory is gone and only claude's own
|
|
256
|
+
# trust record (~/.claude.json) still references it. csctl can't
|
|
257
|
+
# act on it (no start, and it never edits claude's files), so it
|
|
258
|
+
# is dropped instead of rendered as a ✖ 缺失 row. Missing-dir
|
|
259
|
+
# projects that ARE actionable (in the autostart list, or with a
|
|
260
|
+
# live/dead tmux window) stay listed.
|
|
261
|
+
continue
|
|
262
|
+
if win is not None:
|
|
263
|
+
status = "dead" if win.dead else "running"
|
|
264
|
+
else:
|
|
265
|
+
status = "stopped"
|
|
266
|
+
entry = projects_map.get(path)
|
|
267
|
+
spawn = entry.get("remoteControlSpawnMode") if isinstance(entry, dict) else None
|
|
268
|
+
result.append(RCProject(
|
|
269
|
+
name=_basename(path), directory=path,
|
|
270
|
+
trusted=path in trusted,
|
|
271
|
+
in_list=path in enabled,
|
|
272
|
+
status=status,
|
|
273
|
+
auto_start=path in enabled,
|
|
274
|
+
rc_at_startup=_read_rc_at_startup(path),
|
|
275
|
+
spawn_mode=str(spawn) if spawn else None,
|
|
276
|
+
dir_exists=dir_exists,
|
|
277
|
+
))
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def order_by_activity(
|
|
282
|
+
projects: list[RCProject], sessions: list[Session]
|
|
283
|
+
) -> list[RCProject]:
|
|
284
|
+
"""PURE: most-recently-active projects first (exact-cwd session join).
|
|
285
|
+
|
|
286
|
+
THE one ordering — the 项目 tab and `csctl rc status` both call it, so the
|
|
287
|
+
two surfaces can't diverge. A session counts toward the project whose
|
|
288
|
+
directory equals its cwd (normpath); no ancestor roll-up — a subdirectory
|
|
289
|
+
where claude ran is a member itself. Never-active projects sink,
|
|
290
|
+
path-ascending, so broad-root members (a trusted `/tmp`) stay out of the
|
|
291
|
+
way instead of crowding the launcher's top.
|
|
292
|
+
"""
|
|
293
|
+
latest: dict[str, float] = {}
|
|
294
|
+
for s in sessions:
|
|
295
|
+
if not s.cwd:
|
|
296
|
+
continue
|
|
297
|
+
key = os.path.normpath(s.cwd)
|
|
298
|
+
if s.mtime > latest.get(key, 0.0):
|
|
299
|
+
latest[key] = s.mtime
|
|
300
|
+
return sorted(
|
|
301
|
+
projects,
|
|
302
|
+
key=lambda p: (-latest.get(os.path.normpath(p.directory), 0.0),
|
|
303
|
+
p.directory),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _capture_env_id(target: str) -> str:
|
|
308
|
+
"""Grep an `env_*` cloud id from a managed server's pane output, or "".
|
|
309
|
+
|
|
310
|
+
The project RC server leaves zero structured footprint; its cloud env id is
|
|
311
|
+
only printed to stdout (`environment=env_…`). This is the single signal we
|
|
312
|
+
can capture locally for the ledger.
|
|
313
|
+
"""
|
|
314
|
+
m = _ENV_ID_RE.search(_tmux_capture_pane(target))
|
|
315
|
+
return m.group(0) if m else ""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def scan_servers() -> list[RCServer]:
|
|
319
|
+
"""All project RC servers: managed (csctl tmux) ∪ external (/proc) — R5/D5.
|
|
320
|
+
|
|
321
|
+
Managed = tmux windows in `cfg.rc_session` (their pane pid IS the server
|
|
322
|
+
pid); external = `/proc`-discovered `claude remote-control --name` processes
|
|
323
|
+
NOT owned by a managed pane. External servers are READ-ONLY (no
|
|
324
|
+
takeover/restart — review gate; sustains the "no auto-restart RC" rule).
|
|
325
|
+
|
|
326
|
+
For managed servers the captured `env_*` cloud id is pushed one-way into the
|
|
327
|
+
ledger via `environments.upsert` (rc → environments only; environments never
|
|
328
|
+
imports rc). Swallows errors → returns whatever it assembled.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
windows = _tmux_windows()
|
|
332
|
+
discovered = proc.scan_rc_servers()
|
|
333
|
+
except Exception:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
by_pid = {p.pid: p for p in discovered}
|
|
337
|
+
managed_pid_set = {w.pid for w in windows if w.pid}
|
|
338
|
+
|
|
339
|
+
servers: list[RCServer] = []
|
|
340
|
+
env_records: list[EnvRecord] = []
|
|
341
|
+
|
|
342
|
+
# Managed windows first — tmux is the authority for "managed". Addressed
|
|
343
|
+
# by the server-unique window id, never by the (collision-prone) name.
|
|
344
|
+
for w in windows:
|
|
345
|
+
status = "dead" if w.dead else "running"
|
|
346
|
+
found = by_pid.get(w.pid) if w.pid else None
|
|
347
|
+
env_id = _capture_env_id(w.wid)
|
|
348
|
+
if env_id:
|
|
349
|
+
prefix, key = split_env_id(env_id)
|
|
350
|
+
if prefix and key:
|
|
351
|
+
env_records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
352
|
+
servers.append(RCServer(
|
|
353
|
+
name=found.name if found else w.name,
|
|
354
|
+
cwd=found.cwd if found else w.path,
|
|
355
|
+
managed=True,
|
|
356
|
+
pid=w.pid or None,
|
|
357
|
+
env_id=env_id or None,
|
|
358
|
+
status=status,
|
|
359
|
+
))
|
|
360
|
+
|
|
361
|
+
# External — discovered procs not owned by any managed pane.
|
|
362
|
+
for p in discovered:
|
|
363
|
+
if p.pid in managed_pid_set:
|
|
364
|
+
continue
|
|
365
|
+
servers.append(RCServer(
|
|
366
|
+
name=p.name, cwd=p.cwd, managed=False,
|
|
367
|
+
pid=p.pid or None, env_id=None, status="running",
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
if env_records:
|
|
371
|
+
environments.upsert(env_records)
|
|
372
|
+
return servers
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def start_one(path: str) -> bool:
|
|
376
|
+
if not os.path.isdir(path):
|
|
377
|
+
return False
|
|
378
|
+
if not is_trusted(path):
|
|
379
|
+
return False
|
|
380
|
+
win = _window_for(path)
|
|
381
|
+
if win is not None:
|
|
382
|
+
if not win.dead:
|
|
383
|
+
return False
|
|
384
|
+
if not stop_one(path):
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
remote_name = _basename(path)
|
|
388
|
+
# Each fresh Remote Control process registers a distinct cloud environment.
|
|
389
|
+
# Keep restart explicit so transient exits do not pile up duplicate mobile
|
|
390
|
+
# environment entries with the same display name.
|
|
391
|
+
cmd = (
|
|
392
|
+
f"cd {shlex.quote(path)} && exec claude remote-control "
|
|
393
|
+
f"--name {shlex.quote(remote_name)} --spawn same-dir"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
target = tmux.run_in_tmux(cfg.rc_session, tmux.session_name_for(path), cmd)
|
|
397
|
+
if target is None:
|
|
398
|
+
return False
|
|
399
|
+
# Declare the window's project — the collision-safe join key `scan` and
|
|
400
|
+
# `stop_one` read back. Until this lands, `pane_current_path` (the `cd`
|
|
401
|
+
# above) covers the same join, so a mid-spawn scan still matches.
|
|
402
|
+
tmux.set_window_option(target, "@csctl_path", path)
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def stop_one(path: str) -> bool:
|
|
407
|
+
win = _window_for(path)
|
|
408
|
+
if win is None:
|
|
409
|
+
return False
|
|
410
|
+
return tmux.kill_window(win.wid)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def stop_all() -> bool:
|
|
414
|
+
return tmux.kill_session(cfg.rc_session)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def start_many(projects: list[str]) -> int:
|
|
418
|
+
count = 0
|
|
419
|
+
for proj in projects:
|
|
420
|
+
if count > 0:
|
|
421
|
+
time.sleep(cfg.rc_stagger)
|
|
422
|
+
if start_one(proj):
|
|
423
|
+
count += 1
|
|
424
|
+
return count
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def start_all_listed() -> int:
|
|
428
|
+
"""Start every project currently enabled in the autostart list."""
|
|
429
|
+
return start_many(list_enabled())
|