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.
@@ -3,7 +3,8 @@ import getpass
3
3
  import json
4
4
  import os
5
5
  import sys
6
- from datetime import datetime
6
+ import time
7
+ from datetime import datetime, timedelta
7
8
 
8
9
  from .claude_refresh import _refresh_claude_sessions
9
10
  from .cli_render import _dim, _info, _success, _warn
@@ -21,7 +22,10 @@ from .errors import CdxError
21
22
  from .health import collect_health_report, format_health_report
22
23
  from .notify import (
23
24
  format_notify_event,
25
+ format_scheduled_notification,
24
26
  parse_notify_args,
27
+ resolve_notify_event,
28
+ schedule_notification_event,
25
29
  send_desktop_notification,
26
30
  wait_for_notification_event,
27
31
  )
@@ -45,6 +49,11 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
45
49
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
46
50
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
47
51
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
52
+ SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
53
+ UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
54
+ CONFIG_USAGE = "Usage: cdx config <name> [--json]"
55
+ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
56
+ LAST_USAGE = "Usage: cdx last [--json]"
48
57
  API_SCHEMA_VERSION = 1
49
58
  HANDOFF_TRANSCRIPT_CHARS = 120000
50
59
 
@@ -69,6 +78,26 @@ def _write_json(ctx, payload):
69
78
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
70
79
 
71
80
 
81
+ def _update_notice_warning(ctx):
82
+ notice = ctx.get("update_notice")
83
+ if not notice:
84
+ return None
85
+ return {
86
+ "code": "update_available",
87
+ "message": f"Update available: cdx-manager {notice['latest_version']}",
88
+ "latest_version": notice["latest_version"],
89
+ "url": notice.get("url"),
90
+ }
91
+
92
+
93
+ def _write_update_notice(ctx):
94
+ notice = ctx.get("update_notice")
95
+ if not notice:
96
+ return
97
+ text = f"Update available: cdx-manager {notice['latest_version']} (current version installed may be older). Run: cdx update"
98
+ ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
99
+
100
+
72
101
  def _format_bytes(value):
73
102
  if value is None:
74
103
  return "n/a"
@@ -302,6 +331,209 @@ def _parse_toggle_args(args, usage):
302
331
  return {"name": parsed["names"][0], "json": parsed["json"]}
303
332
 
304
333
 
334
+ def _parse_fast_value(value):
335
+ text = str(value).strip().lower()
336
+ if text in ("on", "true", "1", "yes"):
337
+ return True
338
+ if text in ("off", "false", "0", "no"):
339
+ return False
340
+ raise CdxError(SET_USAGE)
341
+
342
+
343
+ def _parse_set_args(args):
344
+ parsed = _parse_flag_args(args, {
345
+ "--power": {"key": "power", "type": "str", "default": None},
346
+ "--permission": {"key": "permission", "type": "str", "default": None},
347
+ "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
348
+ "--json": {"key": "json", "type": "bool", "default": False},
349
+ }, SET_USAGE, positionals_key="names", max_positionals=1)
350
+ if len(parsed["names"]) != 1:
351
+ raise CdxError(SET_USAGE)
352
+ settings = {
353
+ key: parsed[key]
354
+ for key in ("power", "permission", "fast")
355
+ if parsed[key] is not None
356
+ }
357
+ if not settings:
358
+ raise CdxError(SET_USAGE)
359
+ return {"name": parsed["names"][0], "settings": settings, "json": parsed["json"]}
360
+
361
+
362
+ def _parse_unset_args(args):
363
+ parsed = _parse_flag_args(args, {
364
+ "--power": {"key": "power", "type": "bool", "default": False},
365
+ "--permission": {"key": "permission", "type": "bool", "default": False},
366
+ "--fast": {"key": "fast", "type": "bool", "default": False},
367
+ "--all": {"key": "all", "type": "bool", "default": False},
368
+ "--json": {"key": "json", "type": "bool", "default": False},
369
+ }, UNSET_USAGE, positionals_key="names", max_positionals=1)
370
+ if len(parsed["names"]) != 1:
371
+ raise CdxError(UNSET_USAGE)
372
+ keys = ["power", "permission", "fast"] if parsed["all"] else [
373
+ key for key in ("power", "permission", "fast") if parsed[key]
374
+ ]
375
+ if not keys:
376
+ raise CdxError(UNSET_USAGE)
377
+ return {"name": parsed["names"][0], "keys": keys, "json": parsed["json"]}
378
+
379
+
380
+ def _parse_config_args(args):
381
+ parsed = _parse_flag_args(args, {
382
+ "--json": {"key": "json", "type": "bool", "default": False},
383
+ }, CONFIG_USAGE, positionals_key="names", max_positionals=1)
384
+ if len(parsed["names"]) != 1:
385
+ raise CdxError(CONFIG_USAGE)
386
+ return {"name": parsed["names"][0], "json": parsed["json"]}
387
+
388
+
389
+ def _parse_positive_int(value):
390
+ try:
391
+ parsed = int(value)
392
+ except (TypeError, ValueError) as error:
393
+ raise CdxError(HISTORY_USAGE) from error
394
+ if parsed < 1:
395
+ raise CdxError(HISTORY_USAGE)
396
+ return parsed
397
+
398
+
399
+ def _local_timezone():
400
+ return datetime.now().astimezone().tzinfo
401
+
402
+
403
+ def _parse_datetime_value(value, usage, end_of_day=False):
404
+ text = str(value or "").strip()
405
+ if not text:
406
+ raise CdxError(usage)
407
+ if text.endswith("Z"):
408
+ text = f"{text[:-1]}+00:00"
409
+ is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
410
+ try:
411
+ parsed = datetime.fromisoformat(text)
412
+ except ValueError as error:
413
+ raise CdxError(usage) from error
414
+ if is_date_only and end_of_day:
415
+ parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
416
+ if parsed.tzinfo is None:
417
+ parsed = parsed.replace(tzinfo=_local_timezone())
418
+ return parsed.astimezone()
419
+
420
+
421
+ def _parse_since_value(value, now, usage):
422
+ text = str(value or "").strip().lower()
423
+ if not text:
424
+ raise CdxError(usage)
425
+ if text == "today":
426
+ return now.replace(hour=0, minute=0, second=0, microsecond=0)
427
+ if text == "yesterday":
428
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
429
+ return today - timedelta(days=1)
430
+ unit = text[-1:]
431
+ amount_text = text[:-1]
432
+ if unit in ("m", "h", "d", "w") and amount_text.isdigit():
433
+ amount = int(amount_text)
434
+ if amount < 1:
435
+ raise CdxError(usage)
436
+ multipliers = {
437
+ "m": timedelta(minutes=amount),
438
+ "h": timedelta(hours=amount),
439
+ "d": timedelta(days=amount),
440
+ "w": timedelta(weeks=amount),
441
+ }
442
+ return now - multipliers[unit]
443
+ return _parse_datetime_value(value, usage, end_of_day=False)
444
+
445
+
446
+ def _format_period_datetime(value):
447
+ if value is None:
448
+ return None
449
+ return value.isoformat(timespec="seconds")
450
+
451
+
452
+ def _parse_history_period(parsed, now):
453
+ since = parsed.get("since")
454
+ from_value = parsed.get("from")
455
+ to_value = parsed.get("to")
456
+ if since and from_value:
457
+ raise CdxError("Usage: cdx history cannot combine --since and --from.")
458
+ start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
459
+ if from_value:
460
+ start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
461
+ end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
462
+ if start and end and start.timestamp() > end.timestamp():
463
+ raise CdxError("Usage: cdx history period start must be before period end.")
464
+ return {
465
+ "from": _format_period_datetime(start),
466
+ "to": _format_period_datetime(end),
467
+ "from_ts": start.timestamp() if start else None,
468
+ "to_ts": end.timestamp() if end else None,
469
+ }
470
+
471
+
472
+ def _has_history_period(period):
473
+ return period.get("from_ts") is not None or period.get("to_ts") is not None
474
+
475
+
476
+ def _parse_entry_timestamp(entry):
477
+ value = entry.get("started_at")
478
+ if not value:
479
+ return None
480
+ text = str(value)
481
+ if text.endswith("Z"):
482
+ text = f"{text[:-1]}+00:00"
483
+ try:
484
+ parsed = datetime.fromisoformat(text)
485
+ except ValueError:
486
+ return None
487
+ if parsed.tzinfo is None:
488
+ parsed = parsed.replace(tzinfo=_local_timezone())
489
+ return parsed.timestamp()
490
+
491
+
492
+ def _filter_history_period(entries, period):
493
+ if not _has_history_period(period):
494
+ return entries
495
+ filtered = []
496
+ start = period.get("from_ts")
497
+ end = period.get("to_ts")
498
+ for entry in entries:
499
+ timestamp = _parse_entry_timestamp(entry)
500
+ if timestamp is None:
501
+ continue
502
+ if start is not None and timestamp < start:
503
+ continue
504
+ if end is not None and timestamp > end:
505
+ continue
506
+ filtered.append(entry)
507
+ return filtered
508
+
509
+
510
+ def _public_history_period(period):
511
+ return {
512
+ "from": period.get("from"),
513
+ "to": period.get("to"),
514
+ }
515
+
516
+
517
+ def _parse_history_args(args, now=None):
518
+ now = now or datetime.now().astimezone()
519
+ parsed = _parse_flag_args(args, {
520
+ "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
521
+ "--summary": {"key": "summary", "type": "bool", "default": False},
522
+ "--since": {"key": "since", "type": "str", "default": None},
523
+ "--from": {"key": "from", "type": "str", "default": None},
524
+ "--to": {"key": "to", "type": "str", "default": None},
525
+ "--json": {"key": "json", "type": "bool", "default": False},
526
+ }, HISTORY_USAGE, positionals_key="names", max_positionals=1)
527
+ period = _parse_history_period(parsed, now)
528
+ return {
529
+ "name": parsed["names"][0] if parsed["names"] else None,
530
+ "limit": parsed["limit"],
531
+ "summary": parsed["summary"],
532
+ "period": period,
533
+ "json": parsed["json"],
534
+ }
535
+
536
+
305
537
  def _read_option_value(args, index, usage):
306
538
  if index + 1 >= len(args):
307
539
  raise CdxError(usage)
@@ -518,6 +750,220 @@ def handle_enable(rest, ctx):
518
750
  return 0
519
751
 
520
752
 
753
+ def _format_launch_config(session):
754
+ launch = session.get("launch") or {}
755
+ return "\n".join([
756
+ f"{session['name']} ({session['provider']})",
757
+ f"power: {launch.get('power') or 'default'}",
758
+ f"permission: {launch.get('permission') or 'default'}",
759
+ f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
760
+ ])
761
+
762
+
763
+ def _format_duration_ms(value):
764
+ if value is None:
765
+ return "-"
766
+ try:
767
+ amount = int(value)
768
+ except (TypeError, ValueError):
769
+ return str(value)
770
+ if amount < 1000:
771
+ return f"{amount}ms"
772
+ seconds = amount / 1000
773
+ if seconds < 60:
774
+ return f"{seconds:.1f}s"
775
+ minutes = int(seconds // 60)
776
+ remaining = int(seconds % 60)
777
+ return f"{minutes}m{remaining:02d}s"
778
+
779
+
780
+ def _summarize_history(entries):
781
+ by_session = {}
782
+ for entry in entries:
783
+ name = entry.get("session_name") or "-"
784
+ row = by_session.setdefault(name, {
785
+ "session_name": name,
786
+ "provider": entry.get("provider") or "-",
787
+ "launches": 0,
788
+ "successes": 0,
789
+ "failures": 0,
790
+ "duration_ms": 0,
791
+ "last_started_at": None,
792
+ })
793
+ row["launches"] += 1
794
+ if entry.get("status") == "success":
795
+ row["successes"] += 1
796
+ elif entry.get("status") == "failed":
797
+ row["failures"] += 1
798
+ try:
799
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
800
+ except (TypeError, ValueError):
801
+ pass
802
+ started = entry.get("started_at")
803
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
804
+ row["last_started_at"] = started
805
+ row["provider"] = entry.get("provider") or row["provider"]
806
+ return sorted(
807
+ by_session.values(),
808
+ key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
809
+ reverse=True,
810
+ )
811
+
812
+
813
+ def _format_history_period(period):
814
+ if not _has_history_period(period or {}):
815
+ return None
816
+ start = _format_period_display(period.get("from")) or "beginning"
817
+ end = _format_period_display(period.get("to")) or "now"
818
+ return f"Period: {start} -> {end}"
819
+
820
+
821
+ def _format_period_display(value):
822
+ if not value:
823
+ return None
824
+ try:
825
+ parsed = datetime.fromisoformat(str(value))
826
+ except ValueError:
827
+ return str(value)
828
+ return parsed.strftime("%Y-%m-%d %H:%M")
829
+
830
+
831
+ def _format_history_summary(entries, period=None, use_color=False):
832
+ from .cli_render import _format_relative_age, _pad_table
833
+
834
+ summary = _summarize_history(entries)
835
+ if not summary:
836
+ return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
837
+ rows = [["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]
838
+ for row in summary:
839
+ rows.append([
840
+ row["session_name"],
841
+ row["provider"],
842
+ str(row["launches"]),
843
+ str(row["successes"]),
844
+ str(row["failures"]),
845
+ _format_duration_ms(row["duration_ms"]),
846
+ _format_relative_age(row.get("last_started_at")),
847
+ ])
848
+ lines = ["Assistant time:"]
849
+ period_line = _format_history_period(period or {})
850
+ if period_line:
851
+ lines.extend([period_line, ""])
852
+ lines.append(_pad_table(rows))
853
+ return "\n".join(lines)
854
+
855
+
856
+ def _format_history(entries, use_color=False):
857
+ from .cli_render import _format_relative_age, _pad_table
858
+
859
+ if not entries:
860
+ return "No launch history."
861
+ rows = [["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]
862
+ for entry in entries:
863
+ transcript_path = entry.get("transcript_path")
864
+ rows.append([
865
+ entry.get("session_name") or "-",
866
+ entry.get("provider") or "-",
867
+ entry.get("status") or "-",
868
+ _format_duration_ms(entry.get("duration_ms")),
869
+ _format_relative_age(entry.get("started_at")),
870
+ os.path.basename(transcript_path) if transcript_path else "-",
871
+ ])
872
+ return "\n".join([
873
+ "Recent launches:",
874
+ _pad_table(rows),
875
+ "",
876
+ _dim("Full transcript paths and cwd are available with --json.", use_color),
877
+ ])
878
+
879
+
880
+ def handle_set(rest, ctx):
881
+ parsed = _parse_set_args(rest)
882
+ session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
883
+ message = f"Updated launch settings for {parsed['name']}"
884
+ if parsed["json"]:
885
+ _write_json(ctx, _json_success("set", message, session=session, launch=session.get("launch") or {}))
886
+ return 0
887
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
888
+ ctx["out"](f"{_format_launch_config(session)}\n")
889
+ return 0
890
+
891
+
892
+ def handle_unset(rest, ctx):
893
+ parsed = _parse_unset_args(rest)
894
+ session = ctx["service"]["unset_launch_settings"](parsed["name"], parsed["keys"])
895
+ message = f"Cleared launch settings for {parsed['name']}"
896
+ if parsed["json"]:
897
+ _write_json(ctx, _json_success("unset", message, session=session, launch=session.get("launch") or {}))
898
+ return 0
899
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
900
+ ctx["out"](f"{_format_launch_config(session)}\n")
901
+ return 0
902
+
903
+
904
+ def handle_config(rest, ctx):
905
+ parsed = _parse_config_args(rest)
906
+ session = ctx["service"]["get_session"](parsed["name"])
907
+ if not session:
908
+ raise CdxError(f"Unknown session: {parsed['name']}")
909
+ message = f"Launch settings for {parsed['name']}"
910
+ if parsed["json"]:
911
+ _write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
912
+ return 0
913
+ ctx["out"](f"{_format_launch_config(session)}\n")
914
+ return 0
915
+
916
+
917
+ def handle_history(rest, ctx):
918
+ now_fn = ctx["options"].get("now") or time.time
919
+ now = datetime.fromtimestamp(now_fn()).astimezone()
920
+ parsed = _parse_history_args(rest, now=now)
921
+ has_period = _has_history_period(parsed["period"])
922
+ limit = 0 if parsed["summary"] or has_period else parsed["limit"]
923
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
924
+ entries = _filter_history_period(entries, parsed["period"])
925
+ if has_period and not parsed["summary"]:
926
+ entries = entries[:parsed["limit"]]
927
+ message = (
928
+ f"Listed launch history for {parsed['name']}"
929
+ if parsed["name"]
930
+ else "Listed launch history"
931
+ )
932
+ if parsed["json"]:
933
+ payload = {"history": entries, "period": _public_history_period(parsed["period"])}
934
+ if parsed["summary"]:
935
+ payload["summary"] = _summarize_history(entries)
936
+ _write_json(ctx, _json_success("history", message, **payload))
937
+ return 0
938
+ if parsed["summary"]:
939
+ ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'])}\n")
940
+ return 0
941
+ ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'])}\n")
942
+ return 0
943
+
944
+
945
+ def _resolve_last_launch_session(ctx):
946
+ for entry in ctx["service"]["get_launch_history"](limit=0):
947
+ name = entry.get("session_name")
948
+ if not name:
949
+ continue
950
+ session = ctx["service"]["get_session"](name)
951
+ if session:
952
+ return session
953
+ raise CdxError("No launch history yet. Run cdx <name> first.")
954
+
955
+
956
+ def handle_last(rest, ctx):
957
+ json_flag, args = _parse_json_flag(rest)
958
+ if args:
959
+ raise CdxError(LAST_USAGE)
960
+ session = _resolve_last_launch_session(ctx)
961
+ if not json_flag:
962
+ message = f"Launching last session: {session['name']}"
963
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
964
+ return handle_launch(session["name"], ctx)
965
+
966
+
521
967
  def handle_clean(rest, ctx):
522
968
  json_flag, args = _parse_json_flag(rest)
523
969
  service = ctx["service"]
@@ -639,6 +1085,38 @@ def handle_notify(rest, ctx):
639
1085
  env=ctx.get("env"),
640
1086
  )
641
1087
 
1088
+ if parsed["schedule"]:
1089
+ event = resolve_notify_event(
1090
+ ctx["service"]["get_status_rows"](
1091
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
1092
+ force_refresh=parsed.get("refresh", False),
1093
+ ),
1094
+ parsed,
1095
+ (ctx["options"].get("now") or time.time)(),
1096
+ )
1097
+ if event["ready"]:
1098
+ notifier(event["title"], event["message"])
1099
+ schedule = {
1100
+ "scheduled": False,
1101
+ "backend": "immediate",
1102
+ "message": event["message"],
1103
+ "target_timestamp": event.get("target_timestamp"),
1104
+ }
1105
+ else:
1106
+ schedule = schedule_notification_event(
1107
+ ctx["service"]["base_dir"],
1108
+ parsed,
1109
+ event,
1110
+ spawn_sync=ctx.get("spawn_sync"),
1111
+ env=ctx.get("env"),
1112
+ now_fn=ctx["options"].get("now"),
1113
+ )
1114
+ if parsed["json"]:
1115
+ _write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
1116
+ else:
1117
+ ctx["out"](f"{format_scheduled_notification(schedule)}\n")
1118
+ return 0
1119
+
642
1120
  event = wait_for_notification_event(
643
1121
  ctx["service"],
644
1122
  parsed,
@@ -785,6 +1263,9 @@ def handle_status(rest, ctx):
785
1263
  }
786
1264
  for item in refresh_errors
787
1265
  ]
1266
+ update_warning = _update_notice_warning(ctx)
1267
+ if update_warning:
1268
+ warnings.append(update_warning)
788
1269
 
789
1270
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
790
1271
  rows = ctx["service"]["get_status_rows"](
@@ -797,6 +1278,7 @@ def handle_status(rest, ctx):
797
1278
  return 0
798
1279
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
799
1280
  _write_refresh_warnings(refresh_errors, ctx)
1281
+ _write_update_notice(ctx)
800
1282
  return 0
801
1283
 
802
1284
  row = next((r for r in rows if r["session_name"] == args[0]), None)
@@ -807,6 +1289,7 @@ def handle_status(rest, ctx):
807
1289
  return 0
808
1290
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
809
1291
  _write_refresh_warnings(refresh_errors, ctx)
1292
+ _write_update_notice(ctx)
810
1293
  return 0
811
1294
 
812
1295
 
@@ -1028,16 +1511,11 @@ def handle_update(rest, ctx):
1028
1511
 
1029
1512
 
1030
1513
  def handle_launch(command, ctx, initial_prompt=None):
1031
- json_flag = "--json" in ctx["options"].get("raw_args", [])
1514
+ json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
1032
1515
  update_notice = ctx.get("update_notice")
1033
1516
  warnings = []
1034
1517
  if update_notice:
1035
- warnings.append({
1036
- "code": "update_available",
1037
- "message": f"Update available: cdx-manager {update_notice['latest_version']}",
1038
- "latest_version": update_notice["latest_version"],
1039
- "url": update_notice.get("url"),
1040
- })
1518
+ warnings.append(_update_notice_warning(ctx))
1041
1519
  session = ctx["service"]["launch_session"](command)
1042
1520
  _ensure_session_authentication(
1043
1521
  session,
@@ -1052,15 +1530,51 @@ def handle_launch(command, ctx, initial_prompt=None):
1052
1530
  message = f"Launching {session['provider']} session {session['name']}"
1053
1531
  if not json_flag:
1054
1532
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
1055
- if update_notice:
1056
- text = f"Update available: cdx-manager {update_notice['latest_version']} (current version installed may be older)."
1057
- if update_notice.get("url"):
1058
- text = f"{text} {update_notice['url']}"
1059
- ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
1060
- _run_interactive_provider_command(
1061
- session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1062
- signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
1063
- )
1533
+ _write_update_notice(ctx)
1534
+ cwd = ctx.get("cwd") or os.getcwd()
1535
+ runtime_run_id = None
1536
+
1537
+ def runtime_lifecycle(event, info):
1538
+ nonlocal runtime_run_id
1539
+ if event == "started":
1540
+ runtime = ctx["service"]["start_session_runtime"](session["name"], info)
1541
+ runtime_run_id = runtime.get("runId")
1542
+ elif event == "finished" and runtime_run_id:
1543
+ ctx["service"]["finish_session_runtime"](
1544
+ session["name"],
1545
+ runtime_run_id,
1546
+ {"status": "stopped", "returncode": info.get("returncode")},
1547
+ )
1548
+
1549
+ try:
1550
+ run_info = _run_interactive_provider_command(
1551
+ session, "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
1552
+ signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
1553
+ lifecycle_callback=runtime_lifecycle,
1554
+ )
1555
+ except CdxError as error:
1556
+ run_info = getattr(error, "run_info", {}) or {}
1557
+ if runtime_run_id:
1558
+ ctx["service"]["finish_session_runtime"](
1559
+ session["name"],
1560
+ runtime_run_id,
1561
+ {"status": "failed", "returncode": run_info.get("returncode")},
1562
+ )
1563
+ if run_info:
1564
+ ctx["service"]["record_launch_history"](session["name"], {
1565
+ "status": "failed",
1566
+ "cwd": cwd,
1567
+ "error": str(error),
1568
+ "exit_code": error.exit_code,
1569
+ **run_info,
1570
+ })
1571
+ raise
1572
+ ctx["service"]["record_launch_history"](session["name"], {
1573
+ "status": "success",
1574
+ "cwd": cwd,
1575
+ "exit_code": 0,
1576
+ **run_info,
1577
+ })
1064
1578
  if json_flag:
1065
1579
  _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
1066
1580
  return 0