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.
@@ -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,
@@ -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
  }
@@ -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
- return value
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["session_name"]]
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['session_name']}",
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)}",