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.
- cc_session_control/__init__.py +3 -0
- cc_session_control/__main__.py +5 -0
- cc_session_control/actions/__init__.py +0 -0
- cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control/actions/session_ops.py +150 -0
- cc_session_control/app.py +264 -0
- cc_session_control/cli.py +288 -0
- cc_session_control/clipboard.py +44 -0
- cc_session_control/config.py +132 -0
- cc_session_control/data/__init__.py +0 -0
- cc_session_control/data/agents.py +12 -0
- cc_session_control/data/cleanup.py +402 -0
- cc_session_control/data/environments.py +444 -0
- cc_session_control/data/liveness.py +140 -0
- cc_session_control/data/proc.py +214 -0
- cc_session_control/data/rc.py +411 -0
- cc_session_control/data/registry.py +155 -0
- cc_session_control/data/sessions.py +188 -0
- cc_session_control/data/snapshot.py +115 -0
- cc_session_control/models.py +170 -0
- cc_session_control/views/__init__.py +0 -0
- cc_session_control/views/_session_row.py +145 -0
- cc_session_control/views/agents.py +293 -0
- cc_session_control/views/rc.py +374 -0
- cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0.dist-info/METADATA +115 -0
- cc_session_control-0.4.0.dist-info/RECORD +31 -0
- cc_session_control-0.4.0.dist-info/WHEEL +5 -0
- cc_session_control-0.4.0.dist-info/entry_points.txt +2 -0
- cc_session_control-0.4.0.dist-info/licenses/LICENSE +21 -0
- cc_session_control-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|