cdx-manager 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -34,10 +34,14 @@ One command to launch any session. Zero auth juggling.
34
34
 
35
35
  - **Multiple accounts, one tool.** Register as many Codex or Claude sessions as you need. Each one gets its own isolated auth environment — no cross-contamination between accounts.
36
36
  - **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
37
+ - **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
37
38
  - **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
38
39
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
40
+ - **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately.
39
41
  - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
40
42
  - **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
43
+ - **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
44
+ - **Update prompts.** Periodic update checks surface `cdx update` directly in the `cdx`, `cdx status`, and launch output when a newer release is available.
41
45
  - **Shared handoff context.** Keep a per-workspace Markdown context, or build one from a source session transcript, and install it into another assistant session before switching providers or accounts.
42
46
  - **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
43
47
  - **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
@@ -127,7 +131,7 @@ For a specific version:
127
131
 
128
132
  ```bash
129
133
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
130
- CDX_VERSION=v0.5.7 sh install.sh
134
+ CDX_VERSION=v0.6.0 sh install.sh
131
135
  ```
132
136
 
133
137
  From source:
@@ -230,6 +234,18 @@ cdx work
230
234
 
231
235
  # Check usage across all sessions
232
236
  cdx status
237
+
238
+ # Notify when the next cooling-down assistant is ready
239
+ cdx ready
240
+ ```
241
+
242
+ ### Next-Ready Notifications
243
+
244
+ Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. It is shorthand for `cdx notify --schedule --next-ready`: cdx picks the next known reset, registers a native OS notification, and exits immediately.
245
+
246
+ ```bash
247
+ cdx ready
248
+ cdx ready --refresh
233
249
  ```
234
250
 
235
251
  ### Persistent Launch Settings
@@ -251,6 +267,22 @@ cdx unset work --all
251
267
 
252
268
  `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set.
253
269
 
270
+ ### Launch History
271
+
272
+ Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
273
+
274
+ ```bash
275
+ cdx history
276
+ cdx history work
277
+ cdx history work --limit 5
278
+ cdx history work --json
279
+ cdx history --summary
280
+ cdx history --summary --since 7d
281
+ cdx history --summary --from 2026-05-01 --to 2026-05-28
282
+ ```
283
+
284
+ `cdx history --summary` aggregates total time per assistant. Add `--since`, `--from`, or `--to` to focus on a period.
285
+
254
286
  ---
255
287
 
256
288
  ## All Commands
@@ -271,6 +303,8 @@ cdx unset work --all
271
303
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
272
304
  | `cdx set <name> [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--json]` | Persist launch settings for a session |
273
305
  | `cdx unset <name> (--power\|--permission\|--fast\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
306
+ | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
307
+ | `cdx last [--json]` | Launch the most recent existing session from launch history |
274
308
  | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
275
309
  | `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
276
310
  | `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports Codex and Claude targets, including cross-provider handoff |
@@ -281,6 +315,7 @@ cdx unset work --all
281
315
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
282
316
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
283
317
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
318
+ | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
284
319
  | `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
285
320
  | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
286
321
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
@@ -313,9 +348,12 @@ Commands with machine-readable output:
313
348
  - `cdx enable ... --json`
314
349
  - `cdx context ... --json`
315
350
  - `cdx handoff ... --json`
351
+ - `cdx history ... --json`
352
+ - `cdx last --json`
316
353
  - `cdx doctor --json`
317
354
  - `cdx repair --json`
318
355
  - `cdx update --json`
356
+ - `cdx ready --json`
319
357
  - `cdx notify ... --json`
320
358
 
321
359
  Success payloads follow a shared envelope:
@@ -452,6 +490,7 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
452
490
  sessions.json # Session registry (versioned, all sessions)
453
491
  state/
454
492
  <encoded-name>.json # Per-session rehydration state
493
+ launch_history.jsonl # Append-only launch history
455
494
  profiles/
456
495
  <encoded-name>/ # Codex session: CODEX_HOME points here
457
496
  log/
@@ -0,0 +1,47 @@
1
+ # Changelog (`0.5.7 -> 0.6.0`)
2
+
3
+ Release date: 2026-05-28
4
+
5
+ ## Major Highlights
6
+
7
+ - Added launch history workflows for reviewing, summarizing, and relaunching assistant sessions.
8
+ - Improved `cdx status` guidance with reset-week countdowns, active-session marking, cleaner blocking quota labels, and periodic update notices.
9
+ - Added shortcut commands that reduce routine assistant switching and waiting friction.
10
+
11
+ ## Launch History and Relaunch
12
+
13
+ - Added `cdx history` to inspect recent assistant launches with provider, result, duration, working directory, launch settings, and transcript path.
14
+ - Added `cdx history --summary` to aggregate assistant time by session.
15
+ - Added period filters for history summaries with `--since`, `--from`, and `--to`.
16
+ - Added `cdx last` to relaunch the most recent existing session from launch history.
17
+ - Skips removed sessions when resolving the last launch target.
18
+
19
+ ## Status and Availability
20
+
21
+ - Added reset-week countdowns to status output.
22
+ - Hid healthy blocking quota labels so status tables stay focused on actionable quota limits.
23
+ - Marked the active launched session in status and list output.
24
+ - Added `cdx ready` as a shortcut for scheduling the next assistant reset notification.
25
+ - Added update notices to `cdx status`, matching the existing periodic update check behavior.
26
+ - Changed visual update notices to show `Run: cdx update` instead of a release URL.
27
+
28
+ ## CLI Ergonomics
29
+
30
+ - Curated the main screen next-actions list around the commands users are most likely to run next.
31
+ - Kept release URLs in JSON warning payloads for integrations while making text output directly actionable.
32
+ - Added `cdx last [--json]` to help output and README command documentation.
33
+
34
+ ## Release Metadata and Documentation
35
+
36
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.0`.
37
+ - Documented `cdx last` and the action-oriented update notices in the README.
38
+
39
+ ## Validation and Regression Coverage
40
+
41
+ - Added regression coverage for launch history, assistant-time summaries, period filters, active session indicators, `cdx ready`, `cdx last`, and update notices in `cdx status`.
42
+ - Added coverage to ensure visual update notices show `Run: cdx update` without leaking the release URL.
43
+
44
+ ## Validation and Regression Evidence
45
+
46
+ - `npm run lint`
47
+ - `npm test`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.5.7"
7
+ version = "0.6.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -18,6 +18,8 @@ from .cli_commands import (
18
18
  handle_export,
19
19
  handle_import,
20
20
  handle_handoff,
21
+ handle_history,
22
+ handle_last,
21
23
  handle_launch,
22
24
  handle_login,
23
25
  handle_logout,
@@ -52,7 +54,7 @@ from .status_view import (
52
54
  )
53
55
  from .update_check import check_for_update
54
56
 
55
- VERSION = "0.5.7"
57
+ VERSION = "0.6.0"
56
58
 
57
59
 
58
60
  # ---------------------------------------------------------------------------
@@ -73,6 +75,8 @@ def _print_help(use_color=False):
73
75
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
74
76
  f" {_style('cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]', '36', use_color)}",
75
77
  f" {_style('cdx unset <name> (--power|--permission|--fast|--all) [--json]', '36', use_color)}",
78
+ f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
79
+ f" {_style('cdx last [--json]', '36', use_color)}",
76
80
  f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
77
81
  f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
78
82
  f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
@@ -89,6 +93,7 @@ def _print_help(use_color=False):
89
93
  f" {_style('cdx doctor [--json]', '36', use_color)}",
90
94
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
91
95
  f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
96
+ f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
92
97
  f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
93
98
  f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
94
99
  f" {_style('cdx <name> [--json]', '36', use_color)}",
@@ -156,8 +161,7 @@ def _update_warning_payload(notice):
156
161
  def _update_warning_text(notice):
157
162
  if not notice:
158
163
  return None
159
- suffix = f" {notice['url']}" if notice.get("url") else ""
160
- return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
164
+ return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}). Run: cdx update"
161
165
 
162
166
 
163
167
  # ---------------------------------------------------------------------------
@@ -227,7 +231,7 @@ def main(argv, options=None):
227
231
  "version": VERSION,
228
232
  "cwd": options.get("cwd") or os.getcwd(),
229
233
  "update_notice": _get_update_notice(service, env, options) if command not in (
230
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "config", "set", "unset", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
234
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
231
235
  ) else None,
232
236
  "use_color": use_color,
233
237
  }
@@ -268,6 +272,11 @@ def main(argv, options=None):
268
272
  if command == "update":
269
273
  return handle_update(rest, ctx)
270
274
 
275
+ if command == "ready":
276
+ if any(arg not in ("--refresh", "--json") for arg in rest):
277
+ raise CdxError("Usage: cdx ready [--refresh] [--json]")
278
+ return handle_notify(["--next-ready", "--schedule", *rest], ctx)
279
+
271
280
  if command == "notify":
272
281
  return handle_notify(rest, ctx)
273
282
 
@@ -283,6 +292,12 @@ def main(argv, options=None):
283
292
  if command == "unset":
284
293
  return handle_unset(rest, ctx)
285
294
 
295
+ if command == "history":
296
+ return handle_history(rest, ctx)
297
+
298
+ if command == "last":
299
+ return handle_last(rest, ctx)
300
+
286
301
  if command == "handoff":
287
302
  return handle_handoff(rest, ctx)
288
303
 
@@ -4,7 +4,7 @@ import json
4
4
  import os
5
5
  import sys
6
6
  import time
7
- from datetime import datetime
7
+ from datetime import datetime, timedelta
8
8
 
9
9
  from .claude_refresh import _refresh_claude_sessions
10
10
  from .cli_render import _dim, _info, _success, _warn
@@ -52,6 +52,8 @@ HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <targ
52
52
  SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
53
53
  UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
54
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]"
55
57
  API_SCHEMA_VERSION = 1
56
58
  HANDOFF_TRANSCRIPT_CHARS = 120000
57
59
 
@@ -76,6 +78,26 @@ def _write_json(ctx, payload):
76
78
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
77
79
 
78
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
+
79
101
  def _format_bytes(value):
80
102
  if value is None:
81
103
  return "n/a"
@@ -364,6 +386,154 @@ def _parse_config_args(args):
364
386
  return {"name": parsed["names"][0], "json": parsed["json"]}
365
387
 
366
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
+
367
537
  def _read_option_value(args, index, usage):
368
538
  if index + 1 >= len(args):
369
539
  raise CdxError(usage)
@@ -590,6 +760,123 @@ def _format_launch_config(session):
590
760
  ])
591
761
 
592
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
+
593
880
  def handle_set(rest, ctx):
594
881
  parsed = _parse_set_args(rest)
595
882
  session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
@@ -627,6 +914,56 @@ def handle_config(rest, ctx):
627
914
  return 0
628
915
 
629
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
+
630
967
  def handle_clean(rest, ctx):
631
968
  json_flag, args = _parse_json_flag(rest)
632
969
  service = ctx["service"]
@@ -926,6 +1263,9 @@ def handle_status(rest, ctx):
926
1263
  }
927
1264
  for item in refresh_errors
928
1265
  ]
1266
+ update_warning = _update_notice_warning(ctx)
1267
+ if update_warning:
1268
+ warnings.append(update_warning)
929
1269
 
930
1270
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
931
1271
  rows = ctx["service"]["get_status_rows"](
@@ -938,6 +1278,7 @@ def handle_status(rest, ctx):
938
1278
  return 0
939
1279
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
940
1280
  _write_refresh_warnings(refresh_errors, ctx)
1281
+ _write_update_notice(ctx)
941
1282
  return 0
942
1283
 
943
1284
  row = next((r for r in rows if r["session_name"] == args[0]), None)
@@ -948,6 +1289,7 @@ def handle_status(rest, ctx):
948
1289
  return 0
949
1290
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
950
1291
  _write_refresh_warnings(refresh_errors, ctx)
1292
+ _write_update_notice(ctx)
951
1293
  return 0
952
1294
 
953
1295
 
@@ -1169,16 +1511,11 @@ def handle_update(rest, ctx):
1169
1511
 
1170
1512
 
1171
1513
  def handle_launch(command, ctx, initial_prompt=None):
1172
- json_flag = "--json" in ctx["options"].get("raw_args", [])
1514
+ json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
1173
1515
  update_notice = ctx.get("update_notice")
1174
1516
  warnings = []
1175
1517
  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
- })
1518
+ warnings.append(_update_notice_warning(ctx))
1182
1519
  session = ctx["service"]["launch_session"](command)
1183
1520
  _ensure_session_authentication(
1184
1521
  session,
@@ -1193,15 +1530,51 @@ def handle_launch(command, ctx, initial_prompt=None):
1193
1530
  message = f"Launching {session['provider']} session {session['name']}"
1194
1531
  if not json_flag:
1195
1532
  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
- )
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
+ })
1205
1578
  if json_flag:
1206
1579
  _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
1207
1580
  return 0
package/src/cli_render.py CHANGED
@@ -98,6 +98,19 @@ def _dim(text, use_color=False):
98
98
  return _style(text, "2", use_color)
99
99
 
100
100
 
101
+ def _format_launch_text(launch):
102
+ if not launch:
103
+ return "default"
104
+ parts = []
105
+ if launch.get("power"):
106
+ parts.append(launch["power"])
107
+ if launch.get("permission"):
108
+ parts.append(launch["permission"])
109
+ if launch.get("fast") is True:
110
+ parts.append("fast-on")
111
+ return "/".join(parts) or "default"
112
+
113
+
101
114
  def format_error(error, env=None, stderr=None):
102
115
  return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
103
116
 
@@ -116,40 +129,26 @@ def _format_sessions(service, use_color=False):
116
129
  headers = [_style(header, "1", use_color) for header in headers]
117
130
  table_rows = []
118
131
  for r in rows:
119
- parts = [r["name"]]
132
+ name = f"{r['name']}*" if r.get("active") else r["name"]
133
+ parts = [name]
120
134
  if has_provider:
121
135
  parts.append(r.get("provider") or "n/a")
122
136
  status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
123
137
  parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
124
138
  if has_launch:
125
139
  launch = r.get("launch") or {}
126
- if launch:
127
- launch_text = "/".join([
128
- launch.get("power") or "-",
129
- launch.get("permission") or "-",
130
- "fast-on" if launch.get("fast") is True else "fast-off" if launch.get("fast") is False else "-",
131
- ])
132
- else:
133
- launch_text = "default"
134
- parts.append(_dim(launch_text, use_color))
140
+ parts.append(_dim(_format_launch_text(launch), use_color))
135
141
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
136
142
  table_rows.append(parts)
137
143
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
138
144
  lines += [
139
145
  _style("Next actions:", "1", use_color),
140
- f" {_style('cdx add <name>', '36', use_color)}",
141
- f" {_style('cdx <name>', '36', use_color)}",
142
- f" {_style('cdx login <name>', '36', use_color)}",
143
- f" {_style('cdx logout <name>', '36', use_color)}",
144
- f" {_style('cdx config <name>', '36', use_color)}",
146
+ f" {_style('cdx status', '36', use_color)}",
147
+ f" {_style('cdx ready', '36', use_color)}",
145
148
  f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
146
- f" {_style('cdx context show', '36', use_color)}",
147
- f" {_style('cdx handoff <name>', '36', use_color)}",
148
149
  f" {_style('cdx handoff <source> <target>', '36', use_color)}",
149
- f" {_style('cdx disable <name>', '36', use_color)}",
150
- f" {_style('cdx enable <name>', '36', use_color)}",
151
- f" {_style('cdx ren <source> <dest>', '36', use_color)}",
152
- f" {_style('cdx rmv <name>', '36', use_color)}",
153
- f" {_style('cdx status', '36', use_color)}",
150
+ f" {_style('cdx history', '36', use_color)}",
151
+ f" {_style('cdx help', '36', use_color)}",
152
+ f" {_style('cdx update', '36', use_color)}",
154
153
  ]
155
154
  return "\n".join(lines)
@@ -277,7 +277,7 @@ def _signal_name(sig):
277
277
 
278
278
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
279
279
  env_override=None, signal_emitter=None,
280
- initial_prompt=None):
280
+ initial_prompt=None, lifecycle_callback=None):
281
281
  spawn = spawn or subprocess.Popen
282
282
  spec = (
283
283
  _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
@@ -293,11 +293,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
293
293
  **{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
294
294
  )
295
295
 
296
+ start_time = datetime.now(timezone.utc)
297
+
298
+ child_pid = None
299
+
300
+ def run_info(current_spec, returncode=None):
301
+ end_time = datetime.now(timezone.utc)
302
+ return {
303
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
304
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
305
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
306
+ "command": current_spec.get("command"),
307
+ "args": list(current_spec.get("args") or []),
308
+ "label": current_spec.get("label"),
309
+ "transcript_path": current_spec.get("transcript_path"),
310
+ "pid": child_pid,
311
+ "returncode": returncode,
312
+ }
313
+
296
314
  try:
297
315
  child = start_child(spec)
298
316
  except FileNotFoundError as error:
299
317
  spec = _fallback_launch_spec_or_raise(spec, error)
300
318
  child = start_child(spec)
319
+ child_pid = getattr(child, "pid", None) or os.getpid()
320
+ if lifecycle_callback:
321
+ lifecycle_callback("started", run_info(spec))
301
322
 
302
323
  forwarded_signal = None
303
324
  handlers = []
@@ -336,6 +357,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
336
357
  if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
337
358
  spec = _fallback_launch_spec_or_raise(spec)
338
359
  child = start_child(spec)
360
+ child_pid = getattr(child, "pid", None) or os.getpid()
361
+ if lifecycle_callback:
362
+ lifecycle_callback("started", run_info(spec))
339
363
  child.wait()
340
364
  finally:
341
365
  if use_emitter:
@@ -352,14 +376,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
352
376
  pass
353
377
 
354
378
  if forwarded_signal is not None:
355
- raise CdxError(
379
+ error = CdxError(
356
380
  f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
357
381
  _signal_exit_code(forwarded_signal),
358
382
  )
383
+ error.run_info = run_info(spec, returncode=error.exit_code)
384
+ if lifecycle_callback:
385
+ lifecycle_callback("finished", error.run_info)
386
+ raise error
359
387
  if child.returncode != 0:
360
- raise CdxError(
388
+ error = CdxError(
361
389
  f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
362
390
  )
391
+ error.run_info = run_info(spec, returncode=child.returncode)
392
+ if lifecycle_callback:
393
+ lifecycle_callback("finished", error.run_info)
394
+ raise error
395
+ info = run_info(spec, returncode=child.returncode)
396
+ if lifecycle_callback:
397
+ lifecycle_callback("finished", info)
398
+ return info
363
399
 
364
400
 
365
401
  def _fallback_launch_spec_or_raise(spec, original_error=None):
@@ -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,11 +29,13 @@ 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",
@@ -116,6 +119,24 @@ def _local_now_iso():
116
119
  return datetime.now().astimezone().isoformat()
117
120
 
118
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
+
119
140
  def _safe_relpath(path):
120
141
  normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
121
142
  if (
@@ -324,6 +345,32 @@ def create_session_service(options=None):
324
345
  raise CdxError(f"Unsupported provider: {value}")
325
346
  return value
326
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
+
327
374
  def _validate_new_session_name(name):
328
375
  if not name:
329
376
  raise CdxError("Session name is required")
@@ -585,6 +632,56 @@ def create_session_service(options=None):
585
632
  **s, "updatedAt": now, "lastLaunchedAt": now
586
633
  })
587
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
+
588
685
  def ensure_session_state(name):
589
686
  session = store["get_session"](name)
590
687
  if not session:
@@ -669,6 +766,25 @@ def create_session_service(options=None):
669
766
  raise CdxError(f"Unknown session: {name}")
670
767
  return updated
671
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
+
672
788
  def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
673
789
  current_status = session.get("lastStatus")
674
790
  if session.get("enabled", True) is False:
@@ -806,6 +922,7 @@ def create_session_service(options=None):
806
922
  "session_name": s["name"],
807
923
  "provider": s["provider"],
808
924
  "enabled": enabled,
925
+ "active": bool(_session_runtime(s["name"])) if enabled else False,
809
926
  "status": "enabled" if enabled else "disabled",
810
927
  "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
811
928
  "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
@@ -839,6 +956,7 @@ def create_session_service(options=None):
839
956
  "name": s["name"],
840
957
  "provider": s["provider"] if has_multiple else None,
841
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,
842
960
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
843
961
  "status": s.get("lastStatus"),
844
962
  "launch": s.get("launch") or {},
@@ -992,6 +1110,8 @@ def create_session_service(options=None):
992
1110
  "copy_session": copy_session,
993
1111
  "rename_session": rename_session,
994
1112
  "launch_session": launch_session,
1113
+ "start_session_runtime": start_session_runtime,
1114
+ "finish_session_runtime": finish_session_runtime,
995
1115
  "ensure_session_state": ensure_session_state,
996
1116
  "list_sessions": list_sessions,
997
1117
  "get_session": get_session,
@@ -999,6 +1119,8 @@ def create_session_service(options=None):
999
1119
  "set_launch_settings": set_launch_settings,
1000
1120
  "unset_launch_settings": unset_launch_settings,
1001
1121
  "record_status": record_status,
1122
+ "record_launch_history": record_launch_history,
1123
+ "get_launch_history": get_launch_history,
1002
1124
  "update_auth_state": update_auth_state,
1003
1125
  "get_status_rows": get_status_rows,
1004
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)}",