cdx-manager 0.5.6 → 0.6.0
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.
- package/README.md +66 -4
- package/changelogs/CHANGELOGS_0_5_7.md +45 -0
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +36 -6
- package/src/cli_commands.py +531 -17
- package/src/cli_render.py +27 -12
- package/src/notify.py +229 -6
- package/src/provider_runtime.py +84 -5
- package/src/session_service.py +202 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
package/src/session_service.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import base64
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
|
+
import uuid
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from urllib.parse import quote
|
|
9
10
|
|
|
@@ -28,16 +29,21 @@ RESERVED_SESSION_NAMES = {
|
|
|
28
29
|
"export",
|
|
29
30
|
"help",
|
|
30
31
|
"handoff",
|
|
32
|
+
"history",
|
|
31
33
|
"import",
|
|
32
34
|
"login",
|
|
33
35
|
"logout",
|
|
34
36
|
"mv",
|
|
35
37
|
"notify",
|
|
38
|
+
"ready",
|
|
36
39
|
"repair",
|
|
37
40
|
"ren",
|
|
38
41
|
"rename",
|
|
39
42
|
"rmv",
|
|
43
|
+
"config",
|
|
44
|
+
"set",
|
|
40
45
|
"status",
|
|
46
|
+
"unset",
|
|
41
47
|
"update",
|
|
42
48
|
"version",
|
|
43
49
|
"--help",
|
|
@@ -47,6 +53,8 @@ RESERVED_SESSION_NAMES = {
|
|
|
47
53
|
}
|
|
48
54
|
STATUS_CACHE_TTL_SECONDS = 60
|
|
49
55
|
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
56
|
+
LAUNCH_POWER_VALUES = {"low", "medium", "high", "xhigh", "max"}
|
|
57
|
+
LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
|
|
50
58
|
|
|
51
59
|
|
|
52
60
|
def _encode(name):
|
|
@@ -78,10 +86,57 @@ def _seed_codex_auth_from_global(auth_home, env=None):
|
|
|
78
86
|
return True
|
|
79
87
|
|
|
80
88
|
|
|
89
|
+
def _normalize_launch_settings(settings):
|
|
90
|
+
normalized = {}
|
|
91
|
+
if not settings:
|
|
92
|
+
return normalized
|
|
93
|
+
if "power" in settings and settings["power"] is not None:
|
|
94
|
+
power = str(settings["power"]).strip().lower()
|
|
95
|
+
if power not in LAUNCH_POWER_VALUES:
|
|
96
|
+
raise CdxError(f"Unsupported power: {settings['power']}")
|
|
97
|
+
normalized["power"] = power
|
|
98
|
+
if "permission" in settings and settings["permission"] is not None:
|
|
99
|
+
permission = str(settings["permission"]).strip().lower()
|
|
100
|
+
if permission not in LAUNCH_PERMISSION_VALUES:
|
|
101
|
+
raise CdxError(f"Unsupported permission: {settings['permission']}")
|
|
102
|
+
normalized["permission"] = permission
|
|
103
|
+
if "fast" in settings and settings["fast"] is not None:
|
|
104
|
+
value = settings["fast"]
|
|
105
|
+
if isinstance(value, bool):
|
|
106
|
+
normalized["fast"] = value
|
|
107
|
+
else:
|
|
108
|
+
text = str(value).strip().lower()
|
|
109
|
+
if text in ("on", "true", "1", "yes"):
|
|
110
|
+
normalized["fast"] = True
|
|
111
|
+
elif text in ("off", "false", "0", "no"):
|
|
112
|
+
normalized["fast"] = False
|
|
113
|
+
else:
|
|
114
|
+
raise CdxError(f"Unsupported fast value: {settings['fast']}")
|
|
115
|
+
return normalized
|
|
116
|
+
|
|
117
|
+
|
|
81
118
|
def _local_now_iso():
|
|
82
119
|
return datetime.now().astimezone().isoformat()
|
|
83
120
|
|
|
84
121
|
|
|
122
|
+
def _process_is_running(pid):
|
|
123
|
+
try:
|
|
124
|
+
pid = int(pid)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return False
|
|
127
|
+
if pid <= 0:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
os.kill(pid, 0)
|
|
131
|
+
except ProcessLookupError:
|
|
132
|
+
return False
|
|
133
|
+
except PermissionError:
|
|
134
|
+
return True
|
|
135
|
+
except OSError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
85
140
|
def _safe_relpath(path):
|
|
86
141
|
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
87
142
|
if (
|
|
@@ -290,6 +345,32 @@ def create_session_service(options=None):
|
|
|
290
345
|
raise CdxError(f"Unsupported provider: {value}")
|
|
291
346
|
return value
|
|
292
347
|
|
|
348
|
+
def _runtime_is_active(runtime):
|
|
349
|
+
return (
|
|
350
|
+
isinstance(runtime, dict)
|
|
351
|
+
and runtime.get("status") == "running"
|
|
352
|
+
and _process_is_running(runtime.get("pid"))
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _session_runtime(name):
|
|
356
|
+
state = store["read_session_state"](name)
|
|
357
|
+
if not state:
|
|
358
|
+
return None
|
|
359
|
+
runtime = state.get("runtime")
|
|
360
|
+
if _runtime_is_active(runtime):
|
|
361
|
+
return runtime
|
|
362
|
+
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
363
|
+
store["write_session_state"](name, {
|
|
364
|
+
**state,
|
|
365
|
+
"status": "ready",
|
|
366
|
+
"runtime": {
|
|
367
|
+
**runtime,
|
|
368
|
+
"status": "stale",
|
|
369
|
+
"endedAt": _local_now_iso(),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
return None
|
|
373
|
+
|
|
293
374
|
def _validate_new_session_name(name):
|
|
294
375
|
if not name:
|
|
295
376
|
raise CdxError("Session name is required")
|
|
@@ -312,6 +393,7 @@ def create_session_service(options=None):
|
|
|
312
393
|
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
313
394
|
"lastStatusAt": session.get("lastStatusAt"),
|
|
314
395
|
"lastStatus": session.get("lastStatus"),
|
|
396
|
+
"launch": session.get("launch"),
|
|
315
397
|
"auth": session.get("auth"),
|
|
316
398
|
}
|
|
317
399
|
|
|
@@ -459,6 +541,7 @@ def create_session_service(options=None):
|
|
|
459
541
|
"lastLaunchedAt": None,
|
|
460
542
|
"lastStatusAt": None,
|
|
461
543
|
"lastStatus": None,
|
|
544
|
+
**({"launch": source.get("launch")} if source.get("launch") else {}),
|
|
462
545
|
"auth": {
|
|
463
546
|
"status": "unknown",
|
|
464
547
|
"lastCheckedAt": None,
|
|
@@ -549,6 +632,56 @@ def create_session_service(options=None):
|
|
|
549
632
|
**s, "updatedAt": now, "lastLaunchedAt": now
|
|
550
633
|
})
|
|
551
634
|
|
|
635
|
+
def start_session_runtime(name, payload=None):
|
|
636
|
+
session = store["get_session"](name)
|
|
637
|
+
if not session:
|
|
638
|
+
raise CdxError(f"Unknown session: {name}")
|
|
639
|
+
state = store["read_session_state"](name)
|
|
640
|
+
if not state:
|
|
641
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
642
|
+
payload = dict(payload or {})
|
|
643
|
+
now = _local_now_iso()
|
|
644
|
+
runtime = {
|
|
645
|
+
"status": "running",
|
|
646
|
+
"runId": payload.get("runId") or uuid.uuid4().hex,
|
|
647
|
+
"startedAt": payload.get("startedAt") or now,
|
|
648
|
+
"pid": payload.get("pid") or os.getpid(),
|
|
649
|
+
"command": payload.get("command"),
|
|
650
|
+
"label": payload.get("label"),
|
|
651
|
+
"transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
652
|
+
}
|
|
653
|
+
store["write_session_state"](name, {
|
|
654
|
+
**state,
|
|
655
|
+
"status": "running",
|
|
656
|
+
"runtime": runtime,
|
|
657
|
+
})
|
|
658
|
+
return runtime
|
|
659
|
+
|
|
660
|
+
def finish_session_runtime(name, run_id=None, payload=None):
|
|
661
|
+
session = store["get_session"](name)
|
|
662
|
+
if not session:
|
|
663
|
+
raise CdxError(f"Unknown session: {name}")
|
|
664
|
+
state = store["read_session_state"](name)
|
|
665
|
+
if not state:
|
|
666
|
+
return None
|
|
667
|
+
runtime = state.get("runtime") or {}
|
|
668
|
+
if run_id and runtime.get("runId") != run_id:
|
|
669
|
+
return runtime
|
|
670
|
+
payload = dict(payload or {})
|
|
671
|
+
updated_runtime = {
|
|
672
|
+
**runtime,
|
|
673
|
+
"status": payload.get("status") or "stopped",
|
|
674
|
+
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
675
|
+
}
|
|
676
|
+
if "returncode" in payload:
|
|
677
|
+
updated_runtime["returncode"] = payload["returncode"]
|
|
678
|
+
store["write_session_state"](name, {
|
|
679
|
+
**state,
|
|
680
|
+
"status": "ready",
|
|
681
|
+
"runtime": updated_runtime,
|
|
682
|
+
})
|
|
683
|
+
return updated_runtime
|
|
684
|
+
|
|
552
685
|
def ensure_session_state(name):
|
|
553
686
|
session = store["get_session"](name)
|
|
554
687
|
if not session:
|
|
@@ -581,6 +714,47 @@ def create_session_service(options=None):
|
|
|
581
714
|
"updatedAt": now,
|
|
582
715
|
})
|
|
583
716
|
|
|
717
|
+
def set_launch_settings(name, settings):
|
|
718
|
+
session = store["get_session"](name)
|
|
719
|
+
if not session:
|
|
720
|
+
raise CdxError(f"Unknown session: {name}")
|
|
721
|
+
updates = _normalize_launch_settings(settings)
|
|
722
|
+
if not updates:
|
|
723
|
+
raise CdxError("At least one launch setting is required.")
|
|
724
|
+
current = _normalize_launch_settings(session.get("launch") or {})
|
|
725
|
+
launch = {**current, **updates}
|
|
726
|
+
now = _local_now_iso()
|
|
727
|
+
return store["update_session"](name, lambda s: {
|
|
728
|
+
**s,
|
|
729
|
+
"launch": launch,
|
|
730
|
+
"updatedAt": now,
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
def unset_launch_settings(name, keys):
|
|
734
|
+
session = store["get_session"](name)
|
|
735
|
+
if not session:
|
|
736
|
+
raise CdxError(f"Unknown session: {name}")
|
|
737
|
+
if not keys:
|
|
738
|
+
raise CdxError("At least one launch setting is required.")
|
|
739
|
+
allowed = {"power", "permission", "fast"}
|
|
740
|
+
unknown = [key for key in keys if key not in allowed]
|
|
741
|
+
if unknown:
|
|
742
|
+
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
743
|
+
current = dict(session.get("launch") or {})
|
|
744
|
+
for key in keys:
|
|
745
|
+
current.pop(key, None)
|
|
746
|
+
now = _local_now_iso()
|
|
747
|
+
|
|
748
|
+
def updater(s):
|
|
749
|
+
updated = {**s, "updatedAt": now}
|
|
750
|
+
if current:
|
|
751
|
+
updated["launch"] = current
|
|
752
|
+
else:
|
|
753
|
+
updated.pop("launch", None)
|
|
754
|
+
return updated
|
|
755
|
+
|
|
756
|
+
return store["update_session"](name, updater)
|
|
757
|
+
|
|
584
758
|
def record_status(name, payload):
|
|
585
759
|
normalized = _normalize_status_payload(payload)
|
|
586
760
|
updated = store["update_session"](name, lambda s: {
|
|
@@ -592,6 +766,25 @@ def create_session_service(options=None):
|
|
|
592
766
|
raise CdxError(f"Unknown session: {name}")
|
|
593
767
|
return updated
|
|
594
768
|
|
|
769
|
+
def record_launch_history(name, payload):
|
|
770
|
+
session = store["get_session"](name)
|
|
771
|
+
if not session:
|
|
772
|
+
raise CdxError(f"Unknown session: {name}")
|
|
773
|
+
entry = {
|
|
774
|
+
"schema_version": 1,
|
|
775
|
+
"session_name": session["name"],
|
|
776
|
+
"provider": session["provider"],
|
|
777
|
+
"launch": session.get("launch") or {},
|
|
778
|
+
**payload,
|
|
779
|
+
}
|
|
780
|
+
store["append_launch_history"](entry)
|
|
781
|
+
return entry
|
|
782
|
+
|
|
783
|
+
def get_launch_history(name=None, limit=20):
|
|
784
|
+
if name and not store["get_session"](name):
|
|
785
|
+
raise CdxError(f"Unknown session: {name}")
|
|
786
|
+
return store["list_launch_history"](session_name=name, limit=limit)
|
|
787
|
+
|
|
595
788
|
def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
596
789
|
current_status = session.get("lastStatus")
|
|
597
790
|
if session.get("enabled", True) is False:
|
|
@@ -729,6 +922,7 @@ def create_session_service(options=None):
|
|
|
729
922
|
"session_name": s["name"],
|
|
730
923
|
"provider": s["provider"],
|
|
731
924
|
"enabled": enabled,
|
|
925
|
+
"active": bool(_session_runtime(s["name"])) if enabled else False,
|
|
732
926
|
"status": "enabled" if enabled else "disabled",
|
|
733
927
|
"remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
|
|
734
928
|
"remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
|
|
@@ -762,8 +956,10 @@ def create_session_service(options=None):
|
|
|
762
956
|
"name": s["name"],
|
|
763
957
|
"provider": s["provider"] if has_multiple else None,
|
|
764
958
|
"enabled": s.get("enabled", True) is not False,
|
|
959
|
+
"active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
|
|
765
960
|
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
766
961
|
"status": s.get("lastStatus"),
|
|
962
|
+
"launch": s.get("launch") or {},
|
|
767
963
|
"updated_at": _to_local_iso(s.get("updatedAt")),
|
|
768
964
|
} for s in sessions]
|
|
769
965
|
|
|
@@ -914,11 +1110,17 @@ def create_session_service(options=None):
|
|
|
914
1110
|
"copy_session": copy_session,
|
|
915
1111
|
"rename_session": rename_session,
|
|
916
1112
|
"launch_session": launch_session,
|
|
1113
|
+
"start_session_runtime": start_session_runtime,
|
|
1114
|
+
"finish_session_runtime": finish_session_runtime,
|
|
917
1115
|
"ensure_session_state": ensure_session_state,
|
|
918
1116
|
"list_sessions": list_sessions,
|
|
919
1117
|
"get_session": get_session,
|
|
920
1118
|
"set_session_enabled": set_session_enabled,
|
|
1119
|
+
"set_launch_settings": set_launch_settings,
|
|
1120
|
+
"unset_launch_settings": unset_launch_settings,
|
|
921
1121
|
"record_status": record_status,
|
|
1122
|
+
"record_launch_history": record_launch_history,
|
|
1123
|
+
"get_launch_history": get_launch_history,
|
|
922
1124
|
"update_auth_state": update_auth_state,
|
|
923
1125
|
"get_status_rows": get_status_rows,
|
|
924
1126
|
"format_list_rows": format_list_rows,
|
package/src/session_store.py
CHANGED
|
@@ -105,6 +105,7 @@ def create_session_store(base_dir):
|
|
|
105
105
|
store_file = os.path.join(base_dir, "sessions.json")
|
|
106
106
|
lock_file = os.path.join(base_dir, ".sessions.lock")
|
|
107
107
|
state_dir = os.path.join(base_dir, "state")
|
|
108
|
+
launch_history_file = os.path.join(state_dir, "launch_history.jsonl")
|
|
108
109
|
|
|
109
110
|
def _state_file_path(name):
|
|
110
111
|
return os.path.join(state_dir, f"{_encode(name)}.json")
|
|
@@ -259,6 +260,39 @@ def create_session_store(base_dir):
|
|
|
259
260
|
with _file_lock(lock_file):
|
|
260
261
|
_write_session_state_unlocked(name, state)
|
|
261
262
|
|
|
263
|
+
def append_launch_history(entry):
|
|
264
|
+
with _file_lock(lock_file):
|
|
265
|
+
_ensure_dir(state_dir)
|
|
266
|
+
with open(launch_history_file, "a", encoding="utf-8") as handle:
|
|
267
|
+
handle.write(json.dumps(entry, separators=(",", ":")))
|
|
268
|
+
handle.write("\n")
|
|
269
|
+
handle.flush()
|
|
270
|
+
os.fsync(handle.fileno())
|
|
271
|
+
_fsync_directory(state_dir)
|
|
272
|
+
|
|
273
|
+
def list_launch_history(session_name=None, limit=20):
|
|
274
|
+
with _file_lock(lock_file):
|
|
275
|
+
try:
|
|
276
|
+
with open(launch_history_file, "r", encoding="utf-8") as handle:
|
|
277
|
+
lines = handle.readlines()
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return []
|
|
280
|
+
entries = []
|
|
281
|
+
for line in reversed(lines):
|
|
282
|
+
line = line.strip()
|
|
283
|
+
if not line:
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
entry = json.loads(line)
|
|
287
|
+
except json.JSONDecodeError as error:
|
|
288
|
+
raise CdxError(f"Corrupt JSONL file: {launch_history_file}") from error
|
|
289
|
+
if session_name and entry.get("session_name") != session_name:
|
|
290
|
+
continue
|
|
291
|
+
entries.append(entry)
|
|
292
|
+
if limit and len(entries) >= limit:
|
|
293
|
+
break
|
|
294
|
+
return entries
|
|
295
|
+
|
|
262
296
|
return {
|
|
263
297
|
"list_sessions": list_sessions,
|
|
264
298
|
"get_session": get_session,
|
|
@@ -269,4 +303,6 @@ def create_session_store(base_dir):
|
|
|
269
303
|
"replace_session": replace_session,
|
|
270
304
|
"read_session_state": read_session_state,
|
|
271
305
|
"write_session_state": write_session_state,
|
|
306
|
+
"append_launch_history": append_launch_history,
|
|
307
|
+
"list_launch_history": list_launch_history,
|
|
272
308
|
}
|
package/src/status_view.py
CHANGED
|
@@ -11,6 +11,7 @@ from .cli_render import (
|
|
|
11
11
|
|
|
12
12
|
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
13
13
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
14
|
+
BLOCKING_QUOTA_WARNING_THRESHOLD = 10
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _format_reset_time(value):
|
|
@@ -42,7 +43,11 @@ def _format_reset_time(value):
|
|
|
42
43
|
if remaining_minutes == 0:
|
|
43
44
|
return f"in {hours}h"
|
|
44
45
|
return f"in {hours}h {remaining_minutes}m"
|
|
45
|
-
|
|
46
|
+
days = int(delta_s // (24 * 60 * 60))
|
|
47
|
+
hours = int((delta_s % (24 * 60 * 60)) // (60 * 60))
|
|
48
|
+
if hours == 0:
|
|
49
|
+
return f"in {days}d"
|
|
50
|
+
return f"in {days}d {hours}h"
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
def _style_reset_time(value, use_color=False):
|
|
@@ -77,7 +82,7 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
77
82
|
priority = _recommend_priority_sessions(active_rows)
|
|
78
83
|
table_rows = []
|
|
79
84
|
for r in priority + disabled_rows:
|
|
80
|
-
base = [r
|
|
85
|
+
base = [_format_session_name(r)]
|
|
81
86
|
if has_provider:
|
|
82
87
|
base.append(r.get("provider") or "n/a")
|
|
83
88
|
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
@@ -133,6 +138,10 @@ def _format_current_session_line(rows):
|
|
|
133
138
|
return f"Current: last launched {row['session_name']} ({label})."
|
|
134
139
|
|
|
135
140
|
|
|
141
|
+
def _format_session_name(row):
|
|
142
|
+
return f"{row['session_name']}*" if row.get("active") else row["session_name"]
|
|
143
|
+
|
|
144
|
+
|
|
136
145
|
def _recommend_priority_sessions(rows):
|
|
137
146
|
if not rows:
|
|
138
147
|
return []
|
|
@@ -166,6 +175,9 @@ def _format_blocking_quota(row):
|
|
|
166
175
|
remaining_week = row.get("remaining_week_pct")
|
|
167
176
|
if remaining_5h is None and remaining_week is None:
|
|
168
177
|
return "?"
|
|
178
|
+
known = [value for value in (remaining_5h, remaining_week) if value is not None]
|
|
179
|
+
if known and min(known) > BLOCKING_QUOTA_WARNING_THRESHOLD:
|
|
180
|
+
return "-"
|
|
169
181
|
if remaining_5h is None:
|
|
170
182
|
return "WEEK"
|
|
171
183
|
if remaining_week is None:
|
|
@@ -284,7 +296,7 @@ def _now_timestamp():
|
|
|
284
296
|
|
|
285
297
|
def _format_status_detail(row, use_color=False):
|
|
286
298
|
lines = [
|
|
287
|
-
f"{_style('Session:', '1', use_color)} {row
|
|
299
|
+
f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
|
|
288
300
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
289
301
|
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
290
302
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|