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.
- package/README.md +42 -2
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/changelogs/CHANGELOGS_0_6_1.md +30 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +19 -4
- package/src/cli_commands.py +528 -23
- package/src/cli_render.py +21 -22
- package/src/provider_runtime.py +47 -4
- package/src/session_service.py +122 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
- package/src/update_check.py +47 -8
package/src/cli_commands.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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 =
|
|
1748
|
+
transcript_path = _latest_handoff_transcript_path(source)
|
|
1244
1749
|
if not transcript_path:
|
|
1245
|
-
raise CdxError(f"No
|
|
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"))
|