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.
Files changed (66) hide show
  1. {cc_session_control-0.7.2/src/cc_session_control.egg-info → cc_session_control-0.7.3}/PKG-INFO +7 -8
  2. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/README.md +6 -7
  3. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/cli.py +26 -29
  5. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/config.py +0 -37
  6. cc_session_control-0.7.3/src/cc_session_control/data/rc.py +429 -0
  7. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/tmux.py +43 -25
  8. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/models.py +27 -0
  9. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_base.py +16 -3
  10. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/rc.py +15 -9
  11. {cc_session_control-0.7.2 → cc_session_control-0.7.3/src/cc_session_control.egg-info}/PKG-INFO +7 -8
  12. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/SOURCES.txt +1 -0
  13. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_data.py +58 -26
  14. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_rc.py +72 -48
  15. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_smoke.py +0 -7
  16. cc_session_control-0.7.3/tests/test_trust.py +181 -0
  17. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_views.py +22 -3
  18. cc_session_control-0.7.2/src/cc_session_control/data/rc.py +0 -324
  19. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/LICENSE +0 -0
  20. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/pyproject.toml +0 -0
  21. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/setup.cfg +0 -0
  22. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/__main__.py +0 -0
  23. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/__init__.py +0 -0
  24. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/agent_ops.py +0 -0
  25. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/resume_list.py +0 -0
  26. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/session_ops.py +0 -0
  27. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/actions/skill_ops.py +0 -0
  28. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/app.py +0 -0
  29. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/clipboard.py +0 -0
  30. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/__init__.py +0 -0
  31. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/cleanup.py +0 -0
  32. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/environments.py +0 -0
  33. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/liveness.py +0 -0
  34. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/proc.py +0 -0
  35. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/registry.py +0 -0
  36. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/sessions.py +0 -0
  37. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/data/snapshot.py +0 -0
  38. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/skill/SKILL.md +0 -0
  39. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/theme.py +0 -0
  40. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/__init__.py +0 -0
  41. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_colspec.py +0 -0
  42. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_confirm.py +0 -0
  43. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_keytable.py +0 -0
  44. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_rows.py +0 -0
  45. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_session_row.py +0 -0
  46. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
  47. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/agents.py +0 -0
  48. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control/views/sessions.py +0 -0
  49. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  50. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  51. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/requires.txt +0 -0
  52. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/src/cc_session_control.egg-info/top_level.txt +0 -0
  53. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_agent_ops.py +0 -0
  54. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_agents.py +0 -0
  55. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_app.py +0 -0
  56. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_cleanup.py +0 -0
  57. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_cli.py +0 -0
  58. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_environments.py +0 -0
  59. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_liveness.py +0 -0
  60. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_proc.py +0 -0
  61. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_registry.py +0 -0
  62. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_resume_list.py +0 -0
  63. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_sessions.py +0 -0
  64. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_skill_ops.py +0 -0
  65. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_snapshot.py +0 -0
  66. {cc_session_control-0.7.2 → cc_session_control-0.7.3}/tests/test_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.7.2
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 local workspace. Install it separately on
70
- each machine whose sessions you want to manage. For working *on* the code
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 project to RC list and start
82
- csctl rc add myproject # Add by name
83
- csctl rc rm myproject # Remove and stop
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 local workspace. Install it separately on
47
- each machine whose sessions you want to manage. For working *on* the code
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 project to RC list and start
59
- csctl rc add myproject # Add by name
60
- csctl rc rm myproject # Remove and stop
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 |
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.7.3"
@@ -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 name or '.' for current dir")
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 name")
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 name or 'all'")
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
- projects = rc.scan()
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
- proj = args.project
101
- if proj == ".":
102
- ws = str(rc.cfg.workspace)
103
- cwd = os.getcwd()
104
- if cwd.startswith(ws + "/"):
105
- proj = cwd[len(ws) + 1:].split("/")[0]
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(proj)
113
- print(f"Added to list: {proj}")
114
- ok = rc.start_one(proj)
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: ws/{proj}")
111
+ print(f"Started RC server for {path}")
117
112
 
118
113
  elif sub == "rm":
119
- rc.list_rm(args.project)
120
- rc.stop_one(args.project)
121
- print(f"Removed and stopped: {args.project}")
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
- ok = rc.stop_one(args.target)
137
- print(f"Stopped {args.target}" if ok else f"Not running: {args.target}")
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())