cdx-manager 0.5.7 → 0.6.1

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.
@@ -2,9 +2,10 @@ import asyncio
2
2
  import getpass
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import sys
6
7
  import time
7
- from datetime import datetime
8
+ from datetime import datetime, timedelta
8
9
 
9
10
  from .claude_refresh import _refresh_claude_sessions
10
11
  from .cli_render import _dim, _info, _success, _warn
@@ -37,7 +38,7 @@ from .provider_runtime import (
37
38
  from .repair import format_repair_report, repair_health
38
39
  from .backup_bundle import read_bundle_meta
39
40
  from .status_view import _format_status_detail, _format_status_rows
40
- from .update_check import fetch_latest_release, is_newer_version
41
+ from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
41
42
  from .update_manager import build_update_plan, format_update_failure, run_update_plan
42
43
 
43
44
 
@@ -52,8 +53,11 @@ HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <targ
52
53
  SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
53
54
  UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
54
55
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
56
+ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
57
+ LAST_USAGE = "Usage: cdx last [--json]"
55
58
  API_SCHEMA_VERSION = 1
56
59
  HANDOFF_TRANSCRIPT_CHARS = 120000
60
+ HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
57
61
 
58
62
 
59
63
  def _local_now_iso():
@@ -76,6 +80,26 @@ def _write_json(ctx, payload):
76
80
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
77
81
 
78
82
 
83
+ def _update_notice_warning(ctx):
84
+ notice = ctx.get("update_notice")
85
+ if not notice:
86
+ return None
87
+ return {
88
+ "code": "update_available",
89
+ "message": f"Update available: cdx-manager {notice['latest_version']}",
90
+ "latest_version": notice["latest_version"],
91
+ "url": notice.get("url"),
92
+ }
93
+
94
+
95
+ def _write_update_notice(ctx):
96
+ notice = ctx.get("update_notice")
97
+ if not notice:
98
+ return
99
+ text = f"Update available: cdx-manager {notice['latest_version']} (current version installed may be older). Run: cdx update"
100
+ ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
101
+
102
+
79
103
  def _format_bytes(value):
80
104
  if value is None:
81
105
  return "n/a"
@@ -172,9 +196,132 @@ def _latest_launch_transcript_path(session):
172
196
  return max(paths, key=lambda path: (os.path.getmtime(path), path))
173
197
 
174
198
 
175
- def _read_handoff_transcript(path):
199
+ def _get_session_home(session):
200
+ return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
201
+
202
+
203
+ def _safe_stat(path):
204
+ try:
205
+ return os.stat(path)
206
+ except OSError:
207
+ return None
208
+
209
+
210
+ def _sort_recent_paths(paths):
211
+ stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
212
+ return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
213
+
214
+
215
+ def _collect_native_handoff_transcript_paths(session):
216
+ root = _get_session_home(session)
217
+ if not root:
218
+ return []
219
+ candidates = []
220
+ direct = [
221
+ os.path.join(root, "history.jsonl"),
222
+ os.path.join(root, "session_index.jsonl"),
223
+ ]
224
+ candidates.extend(path for path in direct if _safe_stat(path))
225
+
226
+ scan_roots = [
227
+ os.path.join(root, "sessions"),
228
+ os.path.join(root, ".claude", "projects"),
229
+ os.path.join(root, "projects"),
230
+ ]
231
+ skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
232
+ for scan_root in scan_roots:
233
+ if not os.path.isdir(scan_root):
234
+ continue
235
+ for dirpath, dirnames, filenames in os.walk(scan_root):
236
+ dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
237
+ for filename in filenames:
238
+ if filename.endswith(".jsonl") or filename.endswith(".log"):
239
+ candidates.append(os.path.join(dirpath, filename))
240
+ return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
241
+
242
+
243
+ def _latest_handoff_transcript_path(session):
244
+ launch_path = _latest_launch_transcript_path(session)
245
+ if launch_path:
246
+ return launch_path
247
+ native_paths = _collect_native_handoff_transcript_paths(session)
248
+ return native_paths[0] if native_paths else None
249
+
250
+
251
+ def _collect_handoff_text_fragments(value):
252
+ fragments = []
253
+ if isinstance(value, str):
254
+ text = value.strip()
255
+ if text:
256
+ fragments.append(text)
257
+ elif isinstance(value, list):
258
+ for item in value:
259
+ fragments.extend(_collect_handoff_text_fragments(item))
260
+ elif isinstance(value, dict):
261
+ if isinstance(value.get("text"), str):
262
+ fragments.extend(_collect_handoff_text_fragments(value.get("text")))
263
+ if isinstance(value.get("content"), (str, list, dict)):
264
+ fragments.extend(_collect_handoff_text_fragments(value.get("content")))
265
+ if isinstance(value.get("message"), dict):
266
+ fragments.extend(_collect_handoff_text_fragments(value.get("message")))
267
+ if isinstance(value.get("payload"), dict):
268
+ fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
269
+ return fragments
270
+
271
+
272
+ def _jsonl_record_to_handoff_text(record):
273
+ if not isinstance(record, dict):
274
+ return None
275
+ message = record.get("message") if isinstance(record.get("message"), dict) else {}
276
+ role = (
277
+ message.get("role")
278
+ or record.get("role")
279
+ or record.get("type")
280
+ or record.get("sender")
281
+ or "entry"
282
+ )
283
+ text_sources = []
284
+ for key in ("content", "text", "payload"):
285
+ if key in record:
286
+ text_sources.append(record.get(key))
287
+ for key in ("content", "text"):
288
+ if key in message:
289
+ text_sources.append(message.get(key))
290
+ fragments = []
291
+ for value in text_sources:
292
+ fragments.extend(_collect_handoff_text_fragments(value))
293
+ text = "\n".join(fragment for fragment in fragments if fragment).strip()
294
+ if not text:
295
+ return None
296
+ role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
297
+ return f"[{role}]\n{text}"
298
+
299
+
300
+ def _read_jsonl_handoff_transcript(path):
301
+ entries = []
302
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
303
+ for line in handle:
304
+ line = line.strip()
305
+ if not line:
306
+ continue
307
+ try:
308
+ text = _jsonl_record_to_handoff_text(json.loads(line))
309
+ except json.JSONDecodeError:
310
+ text = None
311
+ if text:
312
+ entries.append(text)
313
+ if entries:
314
+ return "\n\n".join(entries)
176
315
  with open(path, "r", encoding="utf-8", errors="replace") as handle:
177
- content = handle.read()
316
+ return handle.read()
317
+
318
+
319
+ def _read_handoff_transcript(path):
320
+ if path.endswith(".jsonl"):
321
+ content = _read_jsonl_handoff_transcript(path)
322
+ else:
323
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
324
+ content = handle.read()
178
325
  if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
179
326
  return content, False
180
327
  return content[-HANDOFF_TRANSCRIPT_CHARS:], True
@@ -364,6 +511,154 @@ def _parse_config_args(args):
364
511
  return {"name": parsed["names"][0], "json": parsed["json"]}
365
512
 
366
513
 
514
+ def _parse_positive_int(value):
515
+ try:
516
+ parsed = int(value)
517
+ except (TypeError, ValueError) as error:
518
+ raise CdxError(HISTORY_USAGE) from error
519
+ if parsed < 1:
520
+ raise CdxError(HISTORY_USAGE)
521
+ return parsed
522
+
523
+
524
+ def _local_timezone():
525
+ return datetime.now().astimezone().tzinfo
526
+
527
+
528
+ def _parse_datetime_value(value, usage, end_of_day=False):
529
+ text = str(value or "").strip()
530
+ if not text:
531
+ raise CdxError(usage)
532
+ if text.endswith("Z"):
533
+ text = f"{text[:-1]}+00:00"
534
+ is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
535
+ try:
536
+ parsed = datetime.fromisoformat(text)
537
+ except ValueError as error:
538
+ raise CdxError(usage) from error
539
+ if is_date_only and end_of_day:
540
+ parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
541
+ if parsed.tzinfo is None:
542
+ parsed = parsed.replace(tzinfo=_local_timezone())
543
+ return parsed.astimezone()
544
+
545
+
546
+ def _parse_since_value(value, now, usage):
547
+ text = str(value or "").strip().lower()
548
+ if not text:
549
+ raise CdxError(usage)
550
+ if text == "today":
551
+ return now.replace(hour=0, minute=0, second=0, microsecond=0)
552
+ if text == "yesterday":
553
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
554
+ return today - timedelta(days=1)
555
+ unit = text[-1:]
556
+ amount_text = text[:-1]
557
+ if unit in ("m", "h", "d", "w") and amount_text.isdigit():
558
+ amount = int(amount_text)
559
+ if amount < 1:
560
+ raise CdxError(usage)
561
+ multipliers = {
562
+ "m": timedelta(minutes=amount),
563
+ "h": timedelta(hours=amount),
564
+ "d": timedelta(days=amount),
565
+ "w": timedelta(weeks=amount),
566
+ }
567
+ return now - multipliers[unit]
568
+ return _parse_datetime_value(value, usage, end_of_day=False)
569
+
570
+
571
+ def _format_period_datetime(value):
572
+ if value is None:
573
+ return None
574
+ return value.isoformat(timespec="seconds")
575
+
576
+
577
+ def _parse_history_period(parsed, now):
578
+ since = parsed.get("since")
579
+ from_value = parsed.get("from")
580
+ to_value = parsed.get("to")
581
+ if since and from_value:
582
+ raise CdxError("Usage: cdx history cannot combine --since and --from.")
583
+ start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
584
+ if from_value:
585
+ start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
586
+ end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
587
+ if start and end and start.timestamp() > end.timestamp():
588
+ raise CdxError("Usage: cdx history period start must be before period end.")
589
+ return {
590
+ "from": _format_period_datetime(start),
591
+ "to": _format_period_datetime(end),
592
+ "from_ts": start.timestamp() if start else None,
593
+ "to_ts": end.timestamp() if end else None,
594
+ }
595
+
596
+
597
+ def _has_history_period(period):
598
+ return period.get("from_ts") is not None or period.get("to_ts") is not None
599
+
600
+
601
+ def _parse_entry_timestamp(entry):
602
+ value = entry.get("started_at")
603
+ if not value:
604
+ return None
605
+ text = str(value)
606
+ if text.endswith("Z"):
607
+ text = f"{text[:-1]}+00:00"
608
+ try:
609
+ parsed = datetime.fromisoformat(text)
610
+ except ValueError:
611
+ return None
612
+ if parsed.tzinfo is None:
613
+ parsed = parsed.replace(tzinfo=_local_timezone())
614
+ return parsed.timestamp()
615
+
616
+
617
+ def _filter_history_period(entries, period):
618
+ if not _has_history_period(period):
619
+ return entries
620
+ filtered = []
621
+ start = period.get("from_ts")
622
+ end = period.get("to_ts")
623
+ for entry in entries:
624
+ timestamp = _parse_entry_timestamp(entry)
625
+ if timestamp is None:
626
+ continue
627
+ if start is not None and timestamp < start:
628
+ continue
629
+ if end is not None and timestamp > end:
630
+ continue
631
+ filtered.append(entry)
632
+ return filtered
633
+
634
+
635
+ def _public_history_period(period):
636
+ return {
637
+ "from": period.get("from"),
638
+ "to": period.get("to"),
639
+ }
640
+
641
+
642
+ def _parse_history_args(args, now=None):
643
+ now = now or datetime.now().astimezone()
644
+ parsed = _parse_flag_args(args, {
645
+ "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
646
+ "--summary": {"key": "summary", "type": "bool", "default": False},
647
+ "--since": {"key": "since", "type": "str", "default": None},
648
+ "--from": {"key": "from", "type": "str", "default": None},
649
+ "--to": {"key": "to", "type": "str", "default": None},
650
+ "--json": {"key": "json", "type": "bool", "default": False},
651
+ }, HISTORY_USAGE, positionals_key="names", max_positionals=1)
652
+ period = _parse_history_period(parsed, now)
653
+ return {
654
+ "name": parsed["names"][0] if parsed["names"] else None,
655
+ "limit": parsed["limit"],
656
+ "summary": parsed["summary"],
657
+ "period": period,
658
+ "json": parsed["json"],
659
+ }
660
+
661
+
367
662
  def _read_option_value(args, index, usage):
368
663
  if index + 1 >= len(args):
369
664
  raise CdxError(usage)
@@ -590,6 +885,123 @@ def _format_launch_config(session):
590
885
  ])
591
886
 
592
887
 
888
+ def _format_duration_ms(value):
889
+ if value is None:
890
+ return "-"
891
+ try:
892
+ amount = int(value)
893
+ except (TypeError, ValueError):
894
+ return str(value)
895
+ if amount < 1000:
896
+ return f"{amount}ms"
897
+ seconds = amount / 1000
898
+ if seconds < 60:
899
+ return f"{seconds:.1f}s"
900
+ minutes = int(seconds // 60)
901
+ remaining = int(seconds % 60)
902
+ return f"{minutes}m{remaining:02d}s"
903
+
904
+
905
+ def _summarize_history(entries):
906
+ by_session = {}
907
+ for entry in entries:
908
+ name = entry.get("session_name") or "-"
909
+ row = by_session.setdefault(name, {
910
+ "session_name": name,
911
+ "provider": entry.get("provider") or "-",
912
+ "launches": 0,
913
+ "successes": 0,
914
+ "failures": 0,
915
+ "duration_ms": 0,
916
+ "last_started_at": None,
917
+ })
918
+ row["launches"] += 1
919
+ if entry.get("status") == "success":
920
+ row["successes"] += 1
921
+ elif entry.get("status") == "failed":
922
+ row["failures"] += 1
923
+ try:
924
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
925
+ except (TypeError, ValueError):
926
+ pass
927
+ started = entry.get("started_at")
928
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
929
+ row["last_started_at"] = started
930
+ row["provider"] = entry.get("provider") or row["provider"]
931
+ return sorted(
932
+ by_session.values(),
933
+ key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
934
+ reverse=True,
935
+ )
936
+
937
+
938
+ def _format_history_period(period):
939
+ if not _has_history_period(period or {}):
940
+ return None
941
+ start = _format_period_display(period.get("from")) or "beginning"
942
+ end = _format_period_display(period.get("to")) or "now"
943
+ return f"Period: {start} -> {end}"
944
+
945
+
946
+ def _format_period_display(value):
947
+ if not value:
948
+ return None
949
+ try:
950
+ parsed = datetime.fromisoformat(str(value))
951
+ except ValueError:
952
+ return str(value)
953
+ return parsed.strftime("%Y-%m-%d %H:%M")
954
+
955
+
956
+ def _format_history_summary(entries, period=None, use_color=False):
957
+ from .cli_render import _format_relative_age, _pad_table
958
+
959
+ summary = _summarize_history(entries)
960
+ if not summary:
961
+ return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
962
+ rows = [["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]
963
+ for row in summary:
964
+ rows.append([
965
+ row["session_name"],
966
+ row["provider"],
967
+ str(row["launches"]),
968
+ str(row["successes"]),
969
+ str(row["failures"]),
970
+ _format_duration_ms(row["duration_ms"]),
971
+ _format_relative_age(row.get("last_started_at")),
972
+ ])
973
+ lines = ["Assistant time:"]
974
+ period_line = _format_history_period(period or {})
975
+ if period_line:
976
+ lines.extend([period_line, ""])
977
+ lines.append(_pad_table(rows))
978
+ return "\n".join(lines)
979
+
980
+
981
+ def _format_history(entries, use_color=False):
982
+ from .cli_render import _format_relative_age, _pad_table
983
+
984
+ if not entries:
985
+ return "No launch history."
986
+ rows = [["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]
987
+ for entry in entries:
988
+ transcript_path = entry.get("transcript_path")
989
+ rows.append([
990
+ entry.get("session_name") or "-",
991
+ entry.get("provider") or "-",
992
+ entry.get("status") or "-",
993
+ _format_duration_ms(entry.get("duration_ms")),
994
+ _format_relative_age(entry.get("started_at")),
995
+ os.path.basename(transcript_path) if transcript_path else "-",
996
+ ])
997
+ return "\n".join([
998
+ "Recent launches:",
999
+ _pad_table(rows),
1000
+ "",
1001
+ _dim("Full transcript paths and cwd are available with --json.", use_color),
1002
+ ])
1003
+
1004
+
593
1005
  def handle_set(rest, ctx):
594
1006
  parsed = _parse_set_args(rest)
595
1007
  session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
@@ -627,6 +1039,56 @@ def handle_config(rest, ctx):
627
1039
  return 0
628
1040
 
629
1041
 
1042
+ def handle_history(rest, ctx):
1043
+ now_fn = ctx["options"].get("now") or time.time
1044
+ now = datetime.fromtimestamp(now_fn()).astimezone()
1045
+ parsed = _parse_history_args(rest, now=now)
1046
+ has_period = _has_history_period(parsed["period"])
1047
+ limit = 0 if parsed["summary"] or has_period else parsed["limit"]
1048
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
1049
+ entries = _filter_history_period(entries, parsed["period"])
1050
+ if has_period and not parsed["summary"]:
1051
+ entries = entries[:parsed["limit"]]
1052
+ message = (
1053
+ f"Listed launch history for {parsed['name']}"
1054
+ if parsed["name"]
1055
+ else "Listed launch history"
1056
+ )
1057
+ if parsed["json"]:
1058
+ payload = {"history": entries, "period": _public_history_period(parsed["period"])}
1059
+ if parsed["summary"]:
1060
+ payload["summary"] = _summarize_history(entries)
1061
+ _write_json(ctx, _json_success("history", message, **payload))
1062
+ return 0
1063
+ if parsed["summary"]:
1064
+ ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'])}\n")
1065
+ return 0
1066
+ ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'])}\n")
1067
+ return 0
1068
+
1069
+
1070
+ def _resolve_last_launch_session(ctx):
1071
+ for entry in ctx["service"]["get_launch_history"](limit=0):
1072
+ name = entry.get("session_name")
1073
+ if not name:
1074
+ continue
1075
+ session = ctx["service"]["get_session"](name)
1076
+ if session:
1077
+ return session
1078
+ raise CdxError("No launch history yet. Run cdx <name> first.")
1079
+
1080
+
1081
+ def handle_last(rest, ctx):
1082
+ json_flag, args = _parse_json_flag(rest)
1083
+ if args:
1084
+ raise CdxError(LAST_USAGE)
1085
+ session = _resolve_last_launch_session(ctx)
1086
+ if not json_flag:
1087
+ message = f"Launching last session: {session['name']}"
1088
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
1089
+ return handle_launch(session["name"], ctx)
1090
+
1091
+
630
1092
  def handle_clean(rest, ctx):
631
1093
  json_flag, args = _parse_json_flag(rest)
632
1094
  service = ctx["service"]
@@ -926,6 +1388,9 @@ def handle_status(rest, ctx):
926
1388
  }
927
1389
  for item in refresh_errors
928
1390
  ]
1391
+ update_warning = _update_notice_warning(ctx)
1392
+ if update_warning:
1393
+ warnings.append(update_warning)
929
1394
 
930
1395
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
931
1396
  rows = ctx["service"]["get_status_rows"](
@@ -938,6 +1403,7 @@ def handle_status(rest, ctx):
938
1403
  return 0
939
1404
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
940
1405
  _write_refresh_warnings(refresh_errors, ctx)
1406
+ _write_update_notice(ctx)
941
1407
  return 0
942
1408
 
943
1409
  row = next((r for r in rows if r["session_name"] == args[0]), None)
@@ -948,6 +1414,7 @@ def handle_status(rest, ctx):
948
1414
  return 0
949
1415
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
950
1416
  _write_refresh_warnings(refresh_errors, ctx)
1417
+ _write_update_notice(ctx)
951
1418
  return 0
952
1419
 
953
1420
 
@@ -1095,7 +1562,14 @@ def handle_update(rest, ctx):
1095
1562
  if parsed["version"] is not None:
1096
1563
  target_version = str(parsed["version"]).strip().lstrip("v")
1097
1564
  else:
1098
- latest = release_fetcher()
1565
+ try:
1566
+ latest = (
1567
+ release_fetcher()
1568
+ if ctx["options"].get("fetchLatestRelease")
1569
+ else fetch_latest_release_or_raise(env=ctx.get("env"))
1570
+ )
1571
+ except LatestReleaseCheckError as error:
1572
+ raise CdxError(str(error)) from error
1099
1573
  if not latest:
1100
1574
  raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
1101
1575
  target_version = str(latest.get("latest_version") or "").strip()
@@ -1169,16 +1643,11 @@ def handle_update(rest, ctx):
1169
1643
 
1170
1644
 
1171
1645
  def handle_launch(command, ctx, initial_prompt=None):
1172
- json_flag = "--json" in ctx["options"].get("raw_args", [])
1646
+ json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
1173
1647
  update_notice = ctx.get("update_notice")
1174
1648
  warnings = []
1175
1649
  if update_notice:
1176
- warnings.append({
1177
- "code": "update_available",
1178
- "message": f"Update available: cdx-manager {update_notice['latest_version']}",
1179
- "latest_version": update_notice["latest_version"],
1180
- "url": update_notice.get("url"),
1181
- })
1650
+ warnings.append(_update_notice_warning(ctx))
1182
1651
  session = ctx["service"]["launch_session"](command)
1183
1652
  _ensure_session_authentication(
1184
1653
  session,
@@ -1193,15 +1662,51 @@ def handle_launch(command, ctx, initial_prompt=None):
1193
1662
  message = f"Launching {session['provider']} session {session['name']}"
1194
1663
  if not json_flag:
1195
1664
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
1196
- if update_notice:
1197
- text = f"Update available: cdx-manager {update_notice['latest_version']} (current version installed may be older)."
1198
- if update_notice.get("url"):
1199
- text = f"{text} {update_notice['url']}"
1200
- ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
1201
- _run_interactive_provider_command(
1202
- session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1203
- signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
1204
- )
1665
+ _write_update_notice(ctx)
1666
+ cwd = ctx.get("cwd") or os.getcwd()
1667
+ runtime_run_id = None
1668
+
1669
+ def runtime_lifecycle(event, info):
1670
+ nonlocal runtime_run_id
1671
+ if event == "started":
1672
+ runtime = ctx["service"]["start_session_runtime"](session["name"], info)
1673
+ runtime_run_id = runtime.get("runId")
1674
+ elif event == "finished" and runtime_run_id:
1675
+ ctx["service"]["finish_session_runtime"](
1676
+ session["name"],
1677
+ runtime_run_id,
1678
+ {"status": "stopped", "returncode": info.get("returncode")},
1679
+ )
1680
+
1681
+ try:
1682
+ run_info = _run_interactive_provider_command(
1683
+ session, "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
1684
+ signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
1685
+ lifecycle_callback=runtime_lifecycle,
1686
+ )
1687
+ except CdxError as error:
1688
+ run_info = getattr(error, "run_info", {}) or {}
1689
+ if runtime_run_id:
1690
+ ctx["service"]["finish_session_runtime"](
1691
+ session["name"],
1692
+ runtime_run_id,
1693
+ {"status": "failed", "returncode": run_info.get("returncode")},
1694
+ )
1695
+ if run_info:
1696
+ ctx["service"]["record_launch_history"](session["name"], {
1697
+ "status": "failed",
1698
+ "cwd": cwd,
1699
+ "error": str(error),
1700
+ "exit_code": error.exit_code,
1701
+ **run_info,
1702
+ })
1703
+ raise
1704
+ ctx["service"]["record_launch_history"](session["name"], {
1705
+ "status": "success",
1706
+ "cwd": cwd,
1707
+ "exit_code": 0,
1708
+ **run_info,
1709
+ })
1205
1710
  if json_flag:
1206
1711
  _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
1207
1712
  return 0
@@ -1240,9 +1745,9 @@ def handle_handoff(rest, ctx):
1240
1745
  target = ctx["service"]["get_session"](target_name)
1241
1746
  if not target:
1242
1747
  raise CdxError(f"Unknown session: {target_name}")
1243
- transcript_path = _latest_launch_transcript_path(source)
1748
+ transcript_path = _latest_handoff_transcript_path(source)
1244
1749
  if not transcript_path:
1245
- raise CdxError(f"No launch transcript found for session: {source_name}")
1750
+ raise CdxError(f"No transcript found for session: {source_name}")
1246
1751
  transcript, truncated = _read_handoff_transcript(transcript_path)
1247
1752
  context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
1248
1753
  write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))