cc-session-control 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,288 @@
1
+ """CLI entry point for csctl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def _build_parser() -> argparse.ArgumentParser:
12
+ from . import __version__
13
+
14
+ parser = argparse.ArgumentParser(
15
+ prog="csctl",
16
+ description="TUI manager for Claude Code sessions and Remote Control",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"csctl {__version__}")
19
+ parser.add_argument("--workspace", type=Path, help="Override workspace root directory")
20
+
21
+ sub = parser.add_subparsers(dest="command")
22
+
23
+ # rc subcommand group
24
+ rc_parser = sub.add_parser("rc", help="Remote Control management")
25
+ rc_sub = rc_parser.add_subparsers(dest="rc_command")
26
+ rc_sub.add_parser("status", help="Show RC status for all projects")
27
+ rc_add = rc_sub.add_parser("add", help="Add project to RC list and start")
28
+ rc_add.add_argument("project", nargs="?", default=".", help="Project name or '.' for current dir")
29
+ rc_rm = rc_sub.add_parser("rm", help="Remove project from RC list and stop")
30
+ rc_rm.add_argument("project", help="Project name")
31
+ rc_sub.add_parser("up", help="Start all listed projects")
32
+ rc_stop = rc_sub.add_parser("stop", help="Stop RC for a project")
33
+ rc_stop.add_argument("target", help="Project name or 'all'")
34
+ rc_sub.add_parser("list", help="Show enabled project list")
35
+
36
+ # prune subcommand
37
+ prune_parser = sub.add_parser("prune", help="Clean up sessions")
38
+ prune_parser.add_argument("--max-prompts", type=int, default=0, help="Max prompt count to prune (default: 0)")
39
+ prune_parser.add_argument("--apply", action="store_true", help="Actually delete (default: dry run)")
40
+ prune_parser.add_argument("--sweep-orphans", action="store_true", help="Clean orphan sid-keyed artifact directories")
41
+ prune_parser.add_argument("--sweep-zombies", action="store_true", help="Remove zombie sessions/<pid>.json files (dead procs; keeps current + alive pids)")
42
+ prune_parser.add_argument("--sweep-aged", action="store_true", help="Remove age-keyed global entries older than cleanup_age_days")
43
+
44
+ # agents subcommand
45
+ sub.add_parser("agents", help="List background agents")
46
+
47
+ # env subcommand
48
+ sub.add_parser("env", help="List bridge environments (current + orphan)")
49
+
50
+ return parser
51
+
52
+
53
+ def _apply_workspace(args: argparse.Namespace) -> None:
54
+ if args.workspace:
55
+ from .config import cfg
56
+ cfg.workspace = args.workspace
57
+
58
+
59
+ def _cmd_rc(args: argparse.Namespace) -> None:
60
+ from .data import rc
61
+
62
+ if not args.rc_command:
63
+ print("Usage: csctl rc <status|add|rm|up|stop|list>")
64
+ sys.exit(1)
65
+
66
+ sub = args.rc_command
67
+
68
+ if sub == "status":
69
+ projects = rc.scan()
70
+ for p in projects:
71
+ icon = {"running": "[running]", "dead": "[dead ]", "stopped": "[stopped]"}.get(p.status, p.status)
72
+ auto = "auto" if p.auto_start else " "
73
+ print(f" {icon} {auto} {p.name}")
74
+
75
+ elif sub == "add":
76
+ proj = args.project
77
+ if proj == ".":
78
+ ws = str(rc.cfg.workspace)
79
+ cwd = os.getcwd()
80
+ if cwd.startswith(ws + "/"):
81
+ proj = cwd[len(ws) + 1:].split("/")[0]
82
+ else:
83
+ print(f"Current directory is not under {ws}. Specify project name explicitly.")
84
+ sys.exit(1)
85
+ if not rc.is_trusted(proj):
86
+ print(f"Not trusted: {proj} — run 'claude' in that directory first to accept the trust dialog")
87
+ sys.exit(1)
88
+ rc.list_add(proj)
89
+ print(f"Added to list: {proj}")
90
+ ok = rc.start_one(proj)
91
+ if ok:
92
+ print(f"Started: ws/{proj}")
93
+
94
+ elif sub == "rm":
95
+ rc.list_rm(args.project)
96
+ rc.stop_one(args.project)
97
+ print(f"Removed and stopped: {args.project}")
98
+
99
+ elif sub == "up":
100
+ enabled = rc.list_enabled()
101
+ if not enabled:
102
+ print("List is empty")
103
+ return
104
+ count = rc.start_many(enabled)
105
+ print(f"Started {count} project(s)")
106
+
107
+ elif sub == "stop":
108
+ if args.target == "all":
109
+ rc.stop_all()
110
+ print("Stopped all")
111
+ else:
112
+ ok = rc.stop_one(args.target)
113
+ print(f"Stopped {args.target}" if ok else f"Not running: {args.target}")
114
+
115
+ elif sub == "list":
116
+ for name in rc.list_enabled():
117
+ print(name)
118
+
119
+
120
+ def _cmd_prune(args: argparse.Namespace) -> None:
121
+ from .data.cleanup import (
122
+ cleanup_stats,
123
+ list_orphan_dirs,
124
+ prune_sessions,
125
+ remove_orphan_dirs,
126
+ remove_session,
127
+ )
128
+ from .data.sessions import scan
129
+
130
+ sessions = scan()
131
+ stats = cleanup_stats(sessions)
132
+ print(f"Total: {stats['total']} Empty: {stats['empty']} Short(<=2): {stats['short']} Orphans: {stats['orphans']}")
133
+
134
+ if args.sweep_orphans:
135
+ orphans = list_orphan_dirs(sessions)
136
+ print(f"Would sweep {len(orphans)} orphan artifact dir(s)")
137
+ if not args.apply:
138
+ print("Dry run. Add --apply to execute.")
139
+ return
140
+ count = remove_orphan_dirs(sessions)
141
+ print(f"Swept {count} orphan dir(s).")
142
+ return
143
+
144
+ if args.sweep_zombies:
145
+ _cmd_prune_zombies(args)
146
+ return
147
+
148
+ if args.sweep_aged:
149
+ _cmd_prune_aged(args)
150
+ return
151
+
152
+ targets = prune_sessions(sessions, max_prompts=args.max_prompts)
153
+ print(f"Would prune {len(targets)} session(s) (<={args.max_prompts} prompts)")
154
+
155
+ if not args.apply:
156
+ print("Dry run. Add --apply to execute.")
157
+ return
158
+
159
+ for s in targets:
160
+ remove_session(s)
161
+ print(f"Pruned {len(targets)} session(s).")
162
+
163
+
164
+ def _cmd_prune_zombies(args: argparse.Namespace) -> None:
165
+ """Strategy A pid-keyed sweep of `sessions/<pid>.json` (R7.1) via the CLI.
166
+
167
+ Reuses the already-gated `data/cleanup` helpers: `select_zombie_pids` keeps
168
+ the current session's pid and any alive pid of a resumed multi-pid sid, and
169
+ `remove_zombie_session_files` refuses without `/proc`. The dry-run preview is
170
+ gated here too — off `/proc` every pid looks dead, so `current` can't be
171
+ determined and we must not even claim the files are sweepable (R10).
172
+ """
173
+ from dataclasses import replace
174
+
175
+ from .data import proc, registry
176
+ from .data.cleanup import remove_zombie_session_files, select_zombie_pids
177
+
178
+ if not proc.current_determinable():
179
+ print("Refused: '/proc' unavailable — cannot determine the current session (R10).")
180
+ return
181
+ procs = [
182
+ replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
183
+ for sp in registry.read_session_procs(max_age=0.0)
184
+ ]
185
+ cur = proc.ancestor_pids()
186
+ zombies = select_zombie_pids(procs, cur)
187
+ print(f"Would sweep {len(zombies)} zombie session file(s)")
188
+ if not args.apply:
189
+ print("Dry run. Add --apply to execute.")
190
+ return
191
+ count = remove_zombie_session_files(procs, cur)
192
+ print(f"Swept {count} zombie session file(s).")
193
+
194
+
195
+ def _cmd_prune_aged(args: argparse.Namespace) -> None:
196
+ """Strategy B age sweep of time/global-keyed dirs (R7.2) via the CLI.
197
+
198
+ `remove_aged_entries` is mtime-only and session-agnostic, so (unlike the
199
+ zombie sweep) it is not gated on `/proc`.
200
+ """
201
+ from .config import cfg
202
+ from .data.cleanup import list_aged_entries, remove_aged_entries
203
+
204
+ aged = list_aged_entries()
205
+ print(f"Would sweep {len(aged)} aged entr(y/ies) older than {cfg.cleanup_age_days}d")
206
+ if not args.apply:
207
+ print("Dry run. Add --apply to execute.")
208
+ return
209
+ count = remove_aged_entries()
210
+ print(f"Swept {count} aged entr(y/ies).")
211
+
212
+
213
+ def _cmd_agents(args: argparse.Namespace) -> None:
214
+ from .actions.agent_ops import job_host
215
+ from .data.registry import read_agent_jobs
216
+
217
+ jobs = read_agent_jobs(max_age=0.0)
218
+ if not jobs:
219
+ print("No background agents found.")
220
+ return
221
+ for job in jobs:
222
+ _pid, alive = job_host(job)
223
+ state = "live" if alive else (job.state or "settled")
224
+ tempo = job.tempo or "-"
225
+ name = job.name or job.short
226
+ print(f" {job.short} [{state}] tempo={tempo} {name} {job.cwd}")
227
+
228
+
229
+ def _cmd_env(args: argparse.Namespace) -> None:
230
+ from .data import environments, rc
231
+
232
+ # Scan RC servers so the env_* namespace is covered too (it has no state
233
+ # file — only a running server references it).
234
+ servers = rc.scan_servers()
235
+ # CURRENT is alive-gated (R3/R6): a zombie session's stale bridge must not be
236
+ # counted as bound. FILE-REFERENCED is the bridge-truthy membership set.
237
+ observed = environments.observe_live(rc_servers=servers, max_age=0.0)
238
+ file_referenced = environments.observe(rc_servers=servers, max_age=0.0)
239
+ # Record every file-referenced env so a later run (after RC toggled off / a job
240
+ # removed) reports it as an orphan = ledger − file-referenced (R6 persistence).
241
+ environments.upsert(file_referenced)
242
+ current = environments.current_envs(observed)
243
+ orphans = environments.orphan_envs(file_referenced)
244
+
245
+ print(f"Current bridge environments: {len(current)}")
246
+ for e in current:
247
+ print(f" {e.env_id} sid={e.bound_sid or '-'}")
248
+
249
+ print(f"Orphan environments (delete manually on claude.ai/code): {len(orphans)}")
250
+ for row in environments.manual_delete_list(file_referenced):
251
+ print(f" {row['env_id']} sid={row['bound_sid'] or '-'}")
252
+
253
+ print(
254
+ "Note: csctl cannot deregister cloud environments; "
255
+ "the orphan list is inherently incomplete "
256
+ "(environments minted while csctl was not running are not tracked)."
257
+ )
258
+
259
+
260
+ def _cmd_tui(args: argparse.Namespace) -> None:
261
+ from .actions.session_ops import do_resume
262
+ from .app import App
263
+
264
+ app = App()
265
+ result = app.run()
266
+
267
+ if result and isinstance(result, tuple) and result[0] == "resume":
268
+ _, session, fork = result
269
+ do_resume(session, fork=fork)
270
+
271
+
272
+ def main() -> None:
273
+ parser = _build_parser()
274
+ args = parser.parse_args()
275
+ _apply_workspace(args)
276
+
277
+ if args.command == "rc":
278
+ _cmd_rc(args)
279
+ elif args.command == "prune":
280
+ _cmd_prune(args)
281
+ elif args.command == "agents":
282
+ _cmd_agents(args)
283
+ elif args.command == "env":
284
+ _cmd_env(args)
285
+ elif args.command is None:
286
+ _cmd_tui(args)
287
+ else:
288
+ parser.print_help()
@@ -0,0 +1,44 @@
1
+ """Cross-platform clipboard support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+
9
+ _backend: list[str] | None = None
10
+ _encoding: str = "utf-8"
11
+
12
+
13
+ def _detect() -> tuple[list[str], str]:
14
+ if os.path.isfile("/mnt/c/Windows/System32/clip.exe"):
15
+ return ["/mnt/c/Windows/System32/clip.exe"], "utf-16-le"
16
+
17
+ if shutil.which("pbcopy"):
18
+ return ["pbcopy"], "utf-8"
19
+
20
+ if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
21
+ return ["wl-copy"], "utf-8"
22
+
23
+ if os.environ.get("DISPLAY") and shutil.which("xclip"):
24
+ return ["xclip", "-selection", "clipboard"], "utf-8"
25
+
26
+ return [], "utf-8"
27
+
28
+
29
+ def copy(text: str) -> bool:
30
+ global _backend, _encoding
31
+ if _backend is None:
32
+ _backend, _encoding = _detect()
33
+
34
+ if not _backend:
35
+ return False
36
+
37
+ try:
38
+ subprocess.run(
39
+ _backend, input=text.encode(_encoding),
40
+ timeout=5, check=True, capture_output=True,
41
+ )
42
+ return True
43
+ except Exception:
44
+ return False
@@ -0,0 +1,132 @@
1
+ """Path detection and configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ class Config:
11
+ def __init__(self) -> None:
12
+ self.claude_home: Path = Path.home() / ".claude"
13
+ self.claude_json: Path = Path.home() / ".claude.json"
14
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
15
+ self.config_dir: Path = Path(xdg) / "csctl"
16
+ self.rc_list: Path = self.config_dir / "rc-enabled"
17
+ self.rc_session: str = os.environ.get("CSCTL_RC_SESSION", "rc")
18
+ self.rc_stagger: int = int(os.environ.get("CSCTL_RC_STAGGER", "2"))
19
+ # Dedicated tmux session for interactive sessions relaunched under remote
20
+ # control (kept separate from rc_session, whose windows are managed RC
21
+ # server processes).
22
+ self.tmux_session: str = os.environ.get("CSCTL_TMUX_SESSION", "cc")
23
+ # Age threshold (days) for the time/global-keyed cleanup strategy.
24
+ self.cleanup_age_days: int = int(os.environ.get("CSCTL_CLEANUP_AGE_DAYS", "14"))
25
+ self._workspace: Path | None = None
26
+
27
+ @property
28
+ def workspace(self) -> Path:
29
+ if self._workspace is not None:
30
+ return self._workspace
31
+ self._workspace = _detect_workspace(self.claude_json)
32
+ return self._workspace
33
+
34
+ @workspace.setter
35
+ def workspace(self, value: Path) -> None:
36
+ self._workspace = value
37
+
38
+ @property
39
+ def projects_root(self) -> Path:
40
+ return self.claude_home / "projects"
41
+
42
+ @property
43
+ def environments_ledger(self) -> Path:
44
+ """csctl's own append-only bridge-environment ledger (R6).
45
+
46
+ Lives under `config_dir` (csctl state, NOT Claude Code's `claude_home`).
47
+ A property so tests that monkeypatch `cfg.config_dir` flow through.
48
+ """
49
+ return self.config_dir / "environments.jsonl"
50
+
51
+ # --- Claude Code state directories (single path authority) ---
52
+ # All derive from claude_home so tests that monkeypatch cfg.claude_home flow
53
+ # through. Never inline `claude_home / "..."` elsewhere — add it here.
54
+
55
+ @property
56
+ def sessions_dir(self) -> Path:
57
+ """Per-pid session registry files (`sessions/<pid>.json`)."""
58
+ return self.claude_home / "sessions"
59
+
60
+ @property
61
+ def jobs_dir(self) -> Path:
62
+ """Background agent job state (`jobs/<short>/state.json`)."""
63
+ return self.claude_home / "jobs"
64
+
65
+ @property
66
+ def session_env_dir(self) -> Path:
67
+ """Per-session env artifacts (`session-env/<sid>`)."""
68
+ return self.claude_home / "session-env"
69
+
70
+ @property
71
+ def file_history_dir(self) -> Path:
72
+ """Per-session file-edit history (`file-history/<sid>`)."""
73
+ return self.claude_home / "file-history"
74
+
75
+ @property
76
+ def shell_snapshots_dir(self) -> Path:
77
+ return self.claude_home / "shell-snapshots"
78
+
79
+ @property
80
+ def telemetry_dir(self) -> Path:
81
+ return self.claude_home / "telemetry"
82
+
83
+ @property
84
+ def plans_dir(self) -> Path:
85
+ return self.claude_home / "plans"
86
+
87
+ @property
88
+ def backups_dir(self) -> Path:
89
+ return self.claude_home / "backups"
90
+
91
+ @property
92
+ def paste_cache_dir(self) -> Path:
93
+ return self.claude_home / "paste-cache"
94
+
95
+ @property
96
+ def debug_dir(self) -> Path:
97
+ return self.claude_home / "debug"
98
+
99
+ @property
100
+ def uploads_dir(self) -> Path:
101
+ return self.claude_home / "uploads"
102
+
103
+ @property
104
+ def tasks_dir(self) -> Path:
105
+ return self.claude_home / "tasks"
106
+
107
+
108
+ def _detect_workspace(claude_json: Path) -> Path:
109
+ env = os.environ.get("CSCTL_WORKSPACE")
110
+ if env:
111
+ return Path(env)
112
+
113
+ default = Path.home() / "workspace"
114
+ if default.is_dir():
115
+ return default
116
+
117
+ try:
118
+ with open(claude_json) as f:
119
+ data = json.load(f)
120
+ dirs = [k for k in data.get("projects", {}) if "/" in k]
121
+ if dirs:
122
+ from os.path import commonpath
123
+ common = Path(commonpath(dirs))
124
+ if common.is_dir() and common != Path.home():
125
+ return common
126
+ except Exception:
127
+ pass
128
+
129
+ return Path.cwd()
130
+
131
+
132
+ cfg = Config()
File without changes
@@ -0,0 +1,12 @@
1
+ """Backward-compatible re-export shim — liveness lives in `liveness.py` now.
2
+
3
+ Kept zero-logic on purpose: re-exporting the *same* callables means terminate's
4
+ `invalidate_cache()` and scan's `alive_map()` share the ONE cache in
5
+ `liveness` (`liveness._cache`). New code should import from `.liveness`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .liveness import alive_map, invalidate_cache
11
+
12
+ __all__ = ["alive_map", "invalidate_cache"]