cdx-manager 0.4.4 → 0.5.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 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.4.4-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.5.1-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
 
@@ -36,7 +36,9 @@ One command to launch any session. Zero auth juggling.
36
36
  - **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
37
37
  - **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
38
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, and last-updated timestamps for every session in one aligned table.
39
- - **Passive status resolution.** If a session has no recorded status, `cdx` reads it directly from the provider's session logs and JSONL history no manual sync required.
39
+ - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
40
+ - **Shared handoff context.** Keep a per-workspace Markdown context and install it into another assistant session before switching providers or accounts.
41
+ - **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.
40
42
  - **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
41
43
  - **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
42
44
 
@@ -52,10 +54,12 @@ One command to launch any session. Zero auth juggling.
52
54
  - Persistence:
53
55
  - Session registry at `~/.cdx/sessions.json` (versioned JSON store).
54
56
  - Per-session state at `~/.cdx/state/<name>.json`.
57
+ - Per-workspace shared context at `~/.cdx/contexts/<workspace-hash>/context.md`.
55
58
  - Auth and provider data under `~/.cdx/profiles/<name>/`.
56
59
  - All paths are URL-encoded to support arbitrary session names.
57
60
  - Status resolution pipeline:
58
61
  - Primary source: recorded status fields on the session record.
62
+ - Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
59
63
  - Fallback: `status-source` scans provider JSONL history files and terminal log transcripts, strips ANSI/OSC sequences, and extracts `usage%`, `5h remaining%`, and `week remaining%` via pattern matching.
60
64
  - Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
61
65
  - If `script` is unavailable, Codex launch falls back to running without transcript capture.
@@ -122,7 +126,7 @@ For a specific version:
122
126
 
123
127
  ```bash
124
128
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
125
- CDX_VERSION=v0.4.4 sh install.sh
129
+ CDX_VERSION=v0.5.1 sh install.sh
126
130
  ```
127
131
 
128
132
  From source:
@@ -242,6 +246,10 @@ cdx status
242
246
  | `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
243
247
  | `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
244
248
  | `cdx logout <name> [--json]` | Log out of a session |
249
+ | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
250
+ | `cdx enable <name> [--json]` | Re-enable a disabled session |
251
+ | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
252
+ | `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
245
253
  | `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
246
254
  | `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
247
255
  | `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
@@ -277,6 +285,10 @@ Commands with machine-readable output:
277
285
  - `cdx import ... --json`
278
286
  - `cdx login ... --json`
279
287
  - `cdx logout ... --json`
288
+ - `cdx disable ... --json`
289
+ - `cdx enable ... --json`
290
+ - `cdx context ... --json`
291
+ - `cdx handoff ... --json`
280
292
  - `cdx doctor --json`
281
293
  - `cdx repair --json`
282
294
  - `cdx update --json`
@@ -435,7 +447,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
435
447
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
436
448
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
437
449
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
438
- - **Status shows `n/a` for all fields** — the session has not been launched yet, or the provider has not written any status output to its history files. Launch the session and run `/status` inside it at least once.
450
+ - **Status shows `n/a` for all fields** — the Codex app-server rate-limit probe may be unavailable, the session may not be authenticated, and no legacy transcript/history status has been captured yet.
439
451
  - **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
440
452
  - **`cdx login` hangs** — the provider's login flow requires a browser or device code. Follow the on-screen instructions in the terminal that opened.
441
453
  - **`make install` says `npm link` is not found** — ensure Node.js and npm are installed and in your PATH.
@@ -0,0 +1,45 @@
1
+ # Changelog (`0.4.4 -> 0.5.0`)
2
+
3
+ Release date: 2026-05-20
4
+
5
+ ## Major Highlights
6
+
7
+ - Added session disable/enable controls so an assistant can be kept in the registry while temporarily removed from launch rotation.
8
+ - Added shared workspace context commands to make provider/account handoff explicit when moving between Codex and Claude sessions.
9
+ - Prepared the package metadata for the 0.5.0 release across npm, PyPI, CLI version output, and README install examples.
10
+
11
+ ## Session Disable Toggle
12
+
13
+ - Added `cdx disable <name> [--json]` and `cdx enable <name> [--json]`.
14
+ - Disabled sessions remain visible in the main list and status views with a `disabled` status.
15
+ - Disabled sessions sort after enabled sessions and are excluded from status priority recommendations.
16
+ - Launching a disabled session now fails with a clear `Session is disabled: <name>` error.
17
+ - Claude live status refresh skips disabled sessions.
18
+
19
+ ## Shared Context Handoff
20
+
21
+ - Added per-workspace shared context storage at `~/.cdx/contexts/<workspace-hash>/context.md`.
22
+ - Added `cdx context show`, `path`, `init`, `edit`, `clear`, and `set`.
23
+ - Added `cdx handoff <name>` to install the current workspace context into the target session as `shared-context.md` before launching it.
24
+ - Added `cdx handoff <name> --json` for non-interactive integrations that only need to install the context and inspect the resulting paths.
25
+ - Reserved `context` and `handoff` as session names to avoid command collisions.
26
+
27
+ ## Packaging
28
+
29
+ - Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.0`.
30
+ - Updated the README version badge, pinned installer example, command table, JSON command list, and data layout notes.
31
+ - Removed the accidental npm self-dependency from `package.json` and regenerated the lockfile.
32
+
33
+ ## Validation and Regression Coverage
34
+
35
+ - Added CLI coverage for context storage, workspace isolation, and handoff installation.
36
+ - Added session-service coverage for disabled-session ordering, launch blocking, and new reserved command names.
37
+ - Kept the full Python test suite green.
38
+
39
+ ## Validation and Regression Evidence
40
+
41
+ - `python -m pytest`
42
+ - `npm run lint`
43
+ - `npm test`
44
+ - `node bin/cdx.js --version`
45
+ - `npm pack --dry-run`
@@ -0,0 +1,33 @@
1
+ # Changelog (`0.5.0 -> 0.5.1`)
2
+
3
+ Release date: 2026-05-22
4
+
5
+ ## Major Highlights
6
+
7
+ - Replaced Codex status scraping with a structured local Codex app-server rate-limit probe.
8
+ - Kept transcript and JSONL status parsing as a legacy fallback when the local Codex app-server is unavailable.
9
+ - Prepared the package metadata for the 0.5.1 release across npm, PyPI, CLI version output, and README install examples.
10
+
11
+ ## Codex Status Resolution
12
+
13
+ - Added a Codex app-server JSON-RPC client that calls `account/rateLimits/read` for each isolated Codex session profile.
14
+ - Normalized app-server rate-limit snapshots into the existing `cdx status` output fields: 5-hour remaining percentage, weekly remaining percentage, reset times, credits, and source metadata.
15
+ - Preserved multi-account isolation by running each probe with the session-specific `CODEX_HOME`.
16
+ - Updated status and launch tips so `/status` transcript capture is no longer presented as the normal Codex refresh path.
17
+
18
+ ## Packaging
19
+
20
+ - Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.1`.
21
+ - Updated the README version badge and pinned installer example for `v0.5.1`.
22
+
23
+ ## Validation and Regression Coverage
24
+
25
+ - Added runtime coverage for Codex app-server JSON-RPC probing and rate-limit normalization.
26
+ - Added session-service coverage proving app-server status is preferred over legacy transcript artifacts.
27
+ - Updated CLI coverage for the revised Codex status guidance.
28
+
29
+ ## Validation and Regression Evidence
30
+
31
+ - `python3 -m unittest discover -s test -p 'test_*_py.py'`
32
+ - `python logics/skills/logics.py lint --require-status`
33
+ - `python3 bin/cdx status --json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -28,6 +28,8 @@
28
28
  "checksums",
29
29
  "changelogs",
30
30
  "src",
31
+ "!src/__pycache__",
32
+ "!src/**/*.pyc",
31
33
  "install.sh",
32
34
  "install.ps1",
33
35
  "pyproject.toml",
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.4.4"
7
+ version = "0.5.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -36,6 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
36
36
  claude_sessions = [
37
37
  s for s in sessions
38
38
  if s["provider"] == "claude"
39
+ and s.get("enabled", True) is not False
39
40
  and (not target_names or s["name"] in target_names)
40
41
  and (force or _is_stale(s, ttl_seconds=ttl_seconds))
41
42
  ]
package/src/cli.py CHANGED
@@ -9,10 +9,14 @@ from .cli_commands import (
9
9
  STATUS_USAGE,
10
10
  handle_add,
11
11
  handle_clean,
12
+ handle_context,
12
13
  handle_copy,
13
14
  handle_doctor,
15
+ handle_disable,
16
+ handle_enable,
14
17
  handle_export,
15
18
  handle_import,
19
+ handle_handoff,
16
20
  handle_launch,
17
21
  handle_login,
18
22
  handle_logout,
@@ -45,7 +49,7 @@ from .status_view import (
45
49
  )
46
50
  from .update_check import check_for_update
47
51
 
48
- VERSION = "0.4.4"
52
+ VERSION = "0.5.1"
49
53
 
50
54
 
51
55
  # ---------------------------------------------------------------------------
@@ -62,11 +66,15 @@ def _print_help(use_color=False):
62
66
  f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
63
67
  f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
64
68
  f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
69
+ f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
70
+ f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
65
71
  f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
66
72
  f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
67
73
  f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
68
74
  f" {_style('cdx login <name> [--json]', '36', use_color)}",
69
75
  f" {_style('cdx logout <name> [--json]', '36', use_color)}",
76
+ f" {_style('cdx disable <name> [--json]', '36', use_color)}",
77
+ f" {_style('cdx enable <name> [--json]', '36', use_color)}",
70
78
  f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
71
79
  f" {_style('cdx clean [name] [--json]', '36', use_color)}",
72
80
  f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
@@ -101,6 +109,8 @@ def format_json_error(error):
101
109
  code = "unknown_command"
102
110
  elif message.startswith("Session already exists:"):
103
111
  code = "session_exists"
112
+ elif message.startswith("Session is disabled:"):
113
+ code = "session_disabled"
104
114
  elif "requires an interactive terminal" in message or "requires confirmation" in message:
105
115
  code = "interactive_terminal_required"
106
116
  return json.dumps({
@@ -208,8 +218,9 @@ def main(argv, options=None):
208
218
  "spawn_sync": spawn_sync,
209
219
  "stdin_is_tty": stdin_is_tty,
210
220
  "version": VERSION,
221
+ "cwd": options.get("cwd") or os.getcwd(),
211
222
  "update_notice": _get_update_notice(service, env, options) if command not in (
212
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "login", "logout", "export", "import", "help", "version"
223
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
213
224
  ) else None,
214
225
  "use_color": use_color,
215
226
  }
@@ -226,6 +237,12 @@ def main(argv, options=None):
226
237
  if command == "rmv":
227
238
  return handle_remove(rest, ctx)
228
239
 
240
+ if command == "disable":
241
+ return handle_disable(rest, ctx)
242
+
243
+ if command == "enable":
244
+ return handle_enable(rest, ctx)
245
+
229
246
  if command == "clean":
230
247
  return handle_clean(rest, ctx)
231
248
 
@@ -247,6 +264,12 @@ def main(argv, options=None):
247
264
  if command == "notify":
248
265
  return handle_notify(rest, ctx)
249
266
 
267
+ if command == "context":
268
+ return handle_context(rest, ctx)
269
+
270
+ if command == "handoff":
271
+ return handle_handoff(rest, ctx)
272
+
250
273
  if command == "status":
251
274
  return handle_status(rest, ctx)
252
275
 
@@ -2,10 +2,20 @@ import asyncio
2
2
  import getpass
3
3
  import json
4
4
  import os
5
+ import sys
5
6
  from datetime import datetime
6
7
 
7
8
  from .claude_refresh import _refresh_claude_sessions
8
9
  from .cli_render import _dim, _info, _success, _warn
10
+ from .context_store import (
11
+ clear_context,
12
+ edit_context,
13
+ get_context_path,
14
+ init_context,
15
+ install_context_for_session,
16
+ read_context,
17
+ write_context,
18
+ )
9
19
  from .errors import CdxError
10
20
  from .health import collect_health_report, format_health_report
11
21
  from .notify import (
@@ -32,6 +42,8 @@ REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
32
42
  UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
33
43
  EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
34
44
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
45
+ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
46
+ HANDOFF_USAGE = "Usage: cdx handoff <name> [--json]"
35
47
  API_SCHEMA_VERSION = 1
36
48
 
37
49
 
@@ -90,6 +102,13 @@ def _parse_remove_args(args):
90
102
  return {"name": names[0], "force": force}
91
103
 
92
104
 
105
+ def _parse_toggle_args(args, usage):
106
+ json_flag, cleaned = _parse_json_flag(args)
107
+ if len(cleaned) != 1:
108
+ raise CdxError(usage)
109
+ return {"name": cleaned[0], "json": json_flag}
110
+
111
+
93
112
  def _read_option_value(args, index, usage):
94
113
  if index + 1 >= len(args):
95
114
  raise CdxError(usage)
@@ -371,6 +390,28 @@ def handle_remove(rest, ctx):
371
390
  return 0
372
391
 
373
392
 
393
+ def handle_disable(rest, ctx):
394
+ parsed = _parse_toggle_args(rest, "Usage: cdx disable <name> [--json]")
395
+ session = ctx["service"]["set_session_enabled"](parsed["name"], False)
396
+ message = f"Disabled session {parsed['name']}"
397
+ if parsed["json"]:
398
+ _write_json(ctx, _json_success("disable", message, session=session))
399
+ return 0
400
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
401
+ return 0
402
+
403
+
404
+ def handle_enable(rest, ctx):
405
+ parsed = _parse_toggle_args(rest, "Usage: cdx enable <name> [--json]")
406
+ session = ctx["service"]["set_session_enabled"](parsed["name"], True)
407
+ message = f"Enabled session {parsed['name']}"
408
+ if parsed["json"]:
409
+ _write_json(ctx, _json_success("enable", message, session=session))
410
+ return 0
411
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
412
+ return 0
413
+
414
+
374
415
  def handle_clean(rest, ctx):
375
416
  json_flag, args = _parse_json_flag(rest)
376
417
  service = ctx["service"]
@@ -507,6 +548,103 @@ def handle_notify(rest, ctx):
507
548
  return 0
508
549
 
509
550
 
551
+ def handle_context(rest, ctx):
552
+ json_flag, args = _parse_json_flag(rest)
553
+ if not args:
554
+ args = ["show"]
555
+ action = args[0]
556
+ base_dir = ctx["service"]["base_dir"]
557
+ cwd = ctx.get("cwd")
558
+
559
+ if action == "show":
560
+ if len(args) != 1:
561
+ raise CdxError(CONTEXT_USAGE)
562
+ content = read_context(base_dir, cwd)
563
+ payload = {
564
+ "path": get_context_path(base_dir, cwd),
565
+ "exists": bool(content.strip()),
566
+ "content": content,
567
+ }
568
+ if json_flag:
569
+ _write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
570
+ return 0
571
+ if content.strip():
572
+ ctx["out"](content if content.endswith("\n") else f"{content}\n")
573
+ else:
574
+ ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
575
+ return 0
576
+
577
+ if action == "path":
578
+ if len(args) != 1:
579
+ raise CdxError(CONTEXT_USAGE)
580
+ path = get_context_path(base_dir, cwd)
581
+ if json_flag:
582
+ _write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
583
+ return 0
584
+ ctx["out"](f"{path}\n")
585
+ return 0
586
+
587
+ if action == "init":
588
+ if len(args) != 1:
589
+ raise CdxError(CONTEXT_USAGE)
590
+ result = init_context(base_dir, cwd)
591
+ message = "Created shared context" if result.get("created") else "Shared context already exists"
592
+ if json_flag:
593
+ _write_json(ctx, _json_success("context.init", message, context=result))
594
+ return 0
595
+ text = f"{message}: {result['path']}"
596
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
597
+ return 0
598
+
599
+ if action == "edit":
600
+ if len(args) != 1:
601
+ raise CdxError(CONTEXT_USAGE)
602
+ result = edit_context(
603
+ base_dir,
604
+ cwd,
605
+ env=ctx.get("env"),
606
+ spawn_sync=ctx.get("spawn_sync"),
607
+ )
608
+ if json_flag:
609
+ _write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
610
+ return 0
611
+ text = f"Edited shared context: {result['path']}"
612
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
613
+ return 0
614
+
615
+ if action == "clear":
616
+ if len(args) != 1:
617
+ raise CdxError(CONTEXT_USAGE)
618
+ result = clear_context(base_dir, cwd)
619
+ message = "Cleared shared context" if result["removed"] else "No shared context to clear"
620
+ if json_flag:
621
+ _write_json(ctx, _json_success("context.clear", message, context=result))
622
+ return 0
623
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
624
+ return 0
625
+
626
+ if action == "set":
627
+ content_args = args[1:]
628
+ if content_args:
629
+ content = " ".join(content_args)
630
+ else:
631
+ stdin = ctx["options"].get("stdin_data")
632
+ if stdin is None and not ctx.get("stdin_is_tty"):
633
+ stdin = sys.stdin.read()
634
+ if stdin is None:
635
+ raise CdxError("Usage: cdx context set <text> [--json]")
636
+ content = stdin
637
+ result = write_context(base_dir, content, cwd)
638
+ if json_flag:
639
+ _write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
640
+ return 0
641
+ text = f"Saved shared context: {result['path']}"
642
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
643
+ return 0
644
+
645
+ raise CdxError(CONTEXT_USAGE)
646
+
647
+
510
648
  def handle_status(rest, ctx):
511
649
  json_flag = "--json" in rest
512
650
  small_flag = "--small" in rest or "-s" in rest
@@ -808,7 +946,7 @@ def handle_launch(command, ctx):
808
946
  ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
809
947
  if session["provider"] == "codex":
810
948
  if not json_flag:
811
- ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
949
+ ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
812
950
  _run_interactive_provider_command(
813
951
  session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
814
952
  signal_emitter=ctx.get("signal_emitter")
@@ -816,3 +954,26 @@ def handle_launch(command, ctx):
816
954
  if json_flag:
817
955
  _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
818
956
  return 0
957
+
958
+
959
+ def handle_handoff(rest, ctx):
960
+ json_flag, args = _parse_json_flag(rest)
961
+ if len(args) != 1:
962
+ raise CdxError(HANDOFF_USAGE)
963
+ name = args[0]
964
+ session = ctx["service"]["get_session"](name)
965
+ if not session:
966
+ raise CdxError(f"Unknown session: {name}")
967
+ install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
968
+ if json_flag:
969
+ _write_json(ctx, _json_success(
970
+ "handoff",
971
+ f"Installed shared context for {name}",
972
+ context=install,
973
+ session=session,
974
+ ))
975
+ return 0
976
+ text = f"Shared context installed for {name}: {install['target_path']}"
977
+ ctx["out"](f"{_info(text, ctx['use_color'])}\n")
978
+ ctx["out"](f"{_dim('Ask the assistant to read shared-context.md if it does not pick it up automatically.', ctx['use_color'])}\n")
979
+ return handle_launch(name, ctx)
package/src/cli_render.py CHANGED
@@ -108,6 +108,7 @@ def _format_sessions(service, use_color=False):
108
108
  headers = ["SESSION"]
109
109
  if has_provider:
110
110
  headers.append("PROVIDER")
111
+ headers.append("STATUS")
111
112
  headers.append("UPDATED")
112
113
  headers = [_style(header, "1", use_color) for header in headers]
113
114
  table_rows = []
@@ -115,6 +116,8 @@ def _format_sessions(service, use_color=False):
115
116
  parts = [r["name"]]
116
117
  if has_provider:
117
118
  parts.append(r.get("provider") or "n/a")
119
+ status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
120
+ parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
118
121
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
119
122
  table_rows.append(parts)
120
123
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
@@ -124,6 +127,10 @@ def _format_sessions(service, use_color=False):
124
127
  f" {_style('cdx <name>', '36', use_color)}",
125
128
  f" {_style('cdx login <name>', '36', use_color)}",
126
129
  f" {_style('cdx logout <name>', '36', use_color)}",
130
+ f" {_style('cdx context show', '36', use_color)}",
131
+ f" {_style('cdx handoff <name>', '36', use_color)}",
132
+ f" {_style('cdx disable <name>', '36', use_color)}",
133
+ f" {_style('cdx enable <name>', '36', use_color)}",
127
134
  f" {_style('cdx ren <source> <dest>', '36', use_color)}",
128
135
  f" {_style('cdx rmv <name>', '36', use_color)}",
129
136
  f" {_style('cdx status', '36', use_color)}",
@@ -0,0 +1,168 @@
1
+ import json
2
+ import os
3
+ import queue
4
+ import subprocess
5
+ import threading
6
+ from datetime import datetime, timezone
7
+
8
+ MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
9
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
10
+
11
+
12
+ def _format_reset_date(unix_seconds):
13
+ if unix_seconds is None:
14
+ return None
15
+ try:
16
+ dt = datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone()
17
+ except (TypeError, ValueError, OSError):
18
+ return None
19
+ return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
20
+
21
+
22
+ def _remaining_from_used_percent(value):
23
+ if value is None:
24
+ return None
25
+ try:
26
+ return max(0, min(100, round(100 - float(value))))
27
+ except (TypeError, ValueError):
28
+ return None
29
+
30
+
31
+ def _get_window(snapshot, duration_mins):
32
+ for key in ("primary", "secondary"):
33
+ window = snapshot.get(key) or {}
34
+ if window.get("windowDurationMins") == duration_mins:
35
+ return window
36
+ if window.get("window_minutes") == duration_mins:
37
+ return window
38
+ return {}
39
+
40
+
41
+ def normalize_codex_rate_limit_snapshot(snapshot):
42
+ if not snapshot:
43
+ return None
44
+
45
+ five_hour = _get_window(snapshot, 300)
46
+ weekly = _get_window(snapshot, 10080)
47
+ credits = snapshot.get("credits")
48
+ credit_balance = None
49
+ if isinstance(credits, dict):
50
+ credit_balance = credits.get("balance")
51
+ if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
52
+ credit_balance = None
53
+ elif credits is not None:
54
+ credit_balance = credits
55
+
56
+ reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
57
+ reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
58
+
59
+ raw_status_text = json.dumps(snapshot, sort_keys=True)
60
+ return {
61
+ "remaining_5h_pct": _remaining_from_used_percent(
62
+ five_hour.get("usedPercent", five_hour.get("used_percent"))
63
+ ),
64
+ "remaining_week_pct": _remaining_from_used_percent(
65
+ weekly.get("usedPercent", weekly.get("used_percent"))
66
+ ),
67
+ "credits": credit_balance,
68
+ "reset_5h_at": reset_5h_at,
69
+ "reset_week_at": reset_week_at,
70
+ "reset_at": reset_week_at or reset_5h_at,
71
+ "updated_at": datetime.now().astimezone().isoformat(),
72
+ "raw_status_text": raw_status_text,
73
+ "source_ref": "api:codex-app-server-rate-limits",
74
+ }
75
+
76
+
77
+ def _reader_thread(stream, output):
78
+ try:
79
+ for line in stream:
80
+ output.put(line)
81
+ finally:
82
+ output.put(None)
83
+
84
+
85
+ def _read_response(output, request_id, timeout):
86
+ deadline = datetime.now().timestamp() + timeout
87
+ while datetime.now().timestamp() < deadline:
88
+ remaining = max(0.01, deadline - datetime.now().timestamp())
89
+ try:
90
+ line = output.get(timeout=remaining)
91
+ except queue.Empty:
92
+ break
93
+ if line is None:
94
+ break
95
+ try:
96
+ message = json.loads(line)
97
+ except (TypeError, json.JSONDecodeError):
98
+ continue
99
+ if message.get("id") == request_id:
100
+ return message
101
+ return None
102
+
103
+
104
+ def _write_json_line(process, payload):
105
+ process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
106
+ process.stdin.flush()
107
+
108
+
109
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
110
+ auth_home = session.get("authHome")
111
+ if not auth_home:
112
+ return None
113
+
114
+ env = os.environ.copy()
115
+ env["CODEX_HOME"] = auth_home
116
+ popen_factory = popen_factory or subprocess.Popen
117
+ process = None
118
+ output = queue.Queue()
119
+ try:
120
+ process = popen_factory(
121
+ ["codex", "app-server", "--listen", "stdio://"],
122
+ stdin=subprocess.PIPE,
123
+ stdout=subprocess.PIPE,
124
+ stderr=subprocess.PIPE,
125
+ env=env,
126
+ text=True,
127
+ bufsize=1,
128
+ )
129
+ thread = threading.Thread(target=_reader_thread, args=(process.stdout, output), daemon=True)
130
+ thread.start()
131
+ _write_json_line(process, {
132
+ "jsonrpc": "2.0",
133
+ "id": 1,
134
+ "method": "initialize",
135
+ "params": {
136
+ "clientInfo": {"name": "cdx-manager", "version": "0"},
137
+ "capabilities": {"experimentalApi": True},
138
+ },
139
+ })
140
+ initialized = _read_response(output, 1, timeout)
141
+ if not initialized or initialized.get("error"):
142
+ return None
143
+
144
+ _write_json_line(process, {
145
+ "jsonrpc": "2.0",
146
+ "id": 2,
147
+ "method": "account/rateLimits/read",
148
+ "params": None,
149
+ })
150
+ response = _read_response(output, 2, timeout)
151
+ if not response or response.get("error"):
152
+ return None
153
+ result = response.get("result") or {}
154
+ by_limit = result.get("rateLimitsByLimitId") or {}
155
+ snapshot = by_limit.get("codex") or result.get("rateLimits")
156
+ return normalize_codex_rate_limit_snapshot(snapshot)
157
+ except (OSError, ValueError, BrokenPipeError):
158
+ return None
159
+ finally:
160
+ if process is not None:
161
+ try:
162
+ process.terminate()
163
+ process.wait(timeout=1)
164
+ except (OSError, subprocess.TimeoutExpired):
165
+ try:
166
+ process.kill()
167
+ except OSError:
168
+ pass
@@ -0,0 +1,127 @@
1
+ import hashlib
2
+ import os
3
+ import shlex
4
+ import subprocess
5
+ from datetime import datetime
6
+
7
+ from .errors import CdxError
8
+ from .session_store import _ensure_dir
9
+
10
+
11
+ DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
12
+
13
+ ## Goal
14
+
15
+ ## Current State
16
+
17
+ ## Decisions
18
+
19
+ ## Files Changed
20
+
21
+ ## Open Questions
22
+
23
+ ## Next Steps
24
+ """
25
+
26
+
27
+ def _local_now_iso():
28
+ return datetime.now().astimezone().isoformat()
29
+
30
+
31
+ def _workspace_key(cwd):
32
+ normalized = os.path.realpath(cwd or os.getcwd())
33
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
34
+ return digest
35
+
36
+
37
+ def _context_dir(base_dir, cwd):
38
+ return os.path.join(base_dir, "contexts", _workspace_key(cwd))
39
+
40
+
41
+ def get_context_path(base_dir, cwd=None):
42
+ return os.path.join(_context_dir(base_dir, cwd), "context.md")
43
+
44
+
45
+ def get_context_meta_path(base_dir, cwd=None):
46
+ return os.path.join(_context_dir(base_dir, cwd), "meta.json")
47
+
48
+
49
+ def read_context(base_dir, cwd=None):
50
+ path = get_context_path(base_dir, cwd)
51
+ try:
52
+ with open(path, "r", encoding="utf-8") as handle:
53
+ return handle.read()
54
+ except FileNotFoundError:
55
+ return ""
56
+
57
+
58
+ def write_context(base_dir, content, cwd=None):
59
+ path = get_context_path(base_dir, cwd)
60
+ _ensure_dir(os.path.dirname(path))
61
+ with open(path, "w", encoding="utf-8") as handle:
62
+ handle.write(content.rstrip() + "\n")
63
+ return {
64
+ "path": path,
65
+ "updated_at": _local_now_iso(),
66
+ "bytes": os.path.getsize(path),
67
+ }
68
+
69
+
70
+ def init_context(base_dir, cwd=None):
71
+ current = read_context(base_dir, cwd)
72
+ if current.strip():
73
+ return {
74
+ "path": get_context_path(base_dir, cwd),
75
+ "created": False,
76
+ "bytes": len(current.encode("utf-8")),
77
+ }
78
+ result = write_context(base_dir, DEFAULT_CONTEXT_TEMPLATE, cwd)
79
+ return {**result, "created": True}
80
+
81
+
82
+ def clear_context(base_dir, cwd=None):
83
+ path = get_context_path(base_dir, cwd)
84
+ try:
85
+ os.remove(path)
86
+ removed = True
87
+ except FileNotFoundError:
88
+ removed = False
89
+ return {"path": path, "removed": removed}
90
+
91
+
92
+ def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
93
+ result = init_context(base_dir, cwd)
94
+ path = result["path"]
95
+ env = env or os.environ
96
+ editor = editor or env.get("VISUAL") or env.get("EDITOR")
97
+ if not editor:
98
+ raise CdxError("Context edit requires VISUAL or EDITOR.")
99
+ spawn_sync = spawn_sync or subprocess.run
100
+ argv = shlex.split(editor) + [path]
101
+ if spawn_sync is subprocess.run:
102
+ completed = subprocess.run(argv)
103
+ returncode = completed.returncode
104
+ else:
105
+ completed = spawn_sync(argv[0], argv[1:], {"env": env})
106
+ returncode = completed.get("returncode", 0) if isinstance(completed, dict) else getattr(completed, "returncode", 0)
107
+ if returncode not in (0, None):
108
+ raise CdxError(f"Context editor exited with code {returncode}")
109
+ return {"path": path, "edited": True}
110
+
111
+
112
+ def install_context_for_session(base_dir, session, cwd=None):
113
+ content = read_context(base_dir, cwd)
114
+ if not content.strip():
115
+ raise CdxError("No shared context for this workspace. Run: cdx context init or cdx context set <text>")
116
+ auth_home = session.get("authHome") or session.get("sessionRoot")
117
+ if not auth_home:
118
+ raise CdxError(f"Session auth home missing for {session['name']}")
119
+ target_path = os.path.join(auth_home, "shared-context.md")
120
+ _ensure_dir(os.path.dirname(target_path))
121
+ with open(target_path, "w", encoding="utf-8") as handle:
122
+ handle.write(content.rstrip() + "\n")
123
+ return {
124
+ "source_path": get_context_path(base_dir, cwd),
125
+ "target_path": target_path,
126
+ "bytes": os.path.getsize(target_path),
127
+ }
@@ -9,6 +9,7 @@ from urllib.parse import quote
9
9
 
10
10
  from .backup_bundle import decode_bundle, encode_bundle
11
11
  from .config import get_cdx_home
12
+ from .codex_usage import fetch_codex_rate_limits
12
13
  from .errors import CdxError
13
14
  from .session_store import create_session_store
14
15
  from .status_source import find_latest_status_artifact
@@ -19,10 +20,14 @@ MAX_SESSION_NAME_LENGTH = 64
19
20
  RESERVED_SESSION_NAMES = {
20
21
  "add",
21
22
  "clean",
23
+ "context",
22
24
  "cp",
25
+ "disable",
23
26
  "doctor",
27
+ "enable",
24
28
  "export",
25
29
  "help",
30
+ "handoff",
26
31
  "import",
27
32
  "login",
28
33
  "logout",
@@ -241,6 +246,7 @@ def create_session_service(options=None):
241
246
  env = options.get("env", os.environ)
242
247
  base_dir = options.get("base_dir") or get_cdx_home(env)
243
248
  store = options.get("store") or create_session_store(base_dir)
249
+ codex_status_fetcher = options.get("fetchCodexRateLimits") or fetch_codex_rate_limits
244
250
 
245
251
  def _get_session_root(name):
246
252
  return os.path.join(base_dir, "profiles", _encode(name))
@@ -273,6 +279,7 @@ def create_session_service(options=None):
273
279
  return {
274
280
  "name": session["name"],
275
281
  "provider": session["provider"],
282
+ "enabled": session.get("enabled", True) is not False,
276
283
  "createdAt": session.get("createdAt"),
277
284
  "updatedAt": session.get("updatedAt"),
278
285
  "lastLaunchedAt": session.get("lastLaunchedAt"),
@@ -325,6 +332,7 @@ def create_session_service(options=None):
325
332
  session = {
326
333
  "name": name,
327
334
  "provider": normalized_provider,
335
+ "enabled": True,
328
336
  "sessionRoot": session_root,
329
337
  "authHome": auth_home,
330
338
  "createdAt": now,
@@ -399,6 +407,7 @@ def create_session_service(options=None):
399
407
  replacement = {
400
408
  "name": dest_name,
401
409
  "provider": source["provider"],
410
+ "enabled": True,
402
411
  "sessionRoot": dest_root,
403
412
  "authHome": dest_auth_home,
404
413
  "createdAt": now,
@@ -485,6 +494,8 @@ def create_session_service(options=None):
485
494
  session = store["get_session"](name)
486
495
  if not session:
487
496
  raise CdxError(f"Unknown session: {name}")
497
+ if session.get("enabled", True) is False:
498
+ raise CdxError(f"Session is disabled: {name}")
488
499
  state = store["read_session_state"](name)
489
500
  if not state:
490
501
  raise CdxError(f"Session state missing for {name}. Reconnect required.")
@@ -515,6 +526,17 @@ def create_session_service(options=None):
515
526
  def get_session(name):
516
527
  return store["get_session"](name)
517
528
 
529
+ def set_session_enabled(name, enabled):
530
+ session = store["get_session"](name)
531
+ if not session:
532
+ raise CdxError(f"Unknown session: {name}")
533
+ now = _local_now_iso()
534
+ return store["update_session"](name, lambda s: {
535
+ **s,
536
+ "enabled": bool(enabled),
537
+ "updatedAt": now,
538
+ })
539
+
518
540
  def record_status(name, payload):
519
541
  normalized = _normalize_status_payload(payload)
520
542
  updated = store["update_session"](name, lambda s: {
@@ -531,6 +553,12 @@ def create_session_service(options=None):
531
553
  source_root = session.get("authHome") or _get_session_auth_home(
532
554
  session["name"], session["provider"]
533
555
  )
556
+ if session["provider"] == "codex" and codex_status_fetcher:
557
+ live_status = codex_status_fetcher({**session, "authHome": source_root})
558
+ if live_status:
559
+ record_status(session["name"], live_status)
560
+ return live_status
561
+
534
562
  expected_account_email = (
535
563
  _read_expected_account_email(source_root)
536
564
  if session["provider"] == "codex"
@@ -605,10 +633,15 @@ def create_session_service(options=None):
605
633
 
606
634
  def sort_key(s):
607
635
  at = s.get("lastStatusAt") or ""
608
- return ("" if at else "\xff", at, s["name"])
636
+ disabled_rank = 1 if s.get("enabled", True) is False else 0
637
+ return (disabled_rank, "" if at else "\xff", at, s["name"])
609
638
 
610
639
  resolved.sort(key=sort_key)
611
- resolved.reverse()
640
+ enabled = [s for s in resolved if s.get("enabled", True) is not False]
641
+ disabled = [s for s in resolved if s.get("enabled", True) is False]
642
+ enabled.reverse()
643
+ disabled.sort(key=lambda s: s["name"])
644
+ resolved = enabled + disabled
612
645
 
613
646
  rows = []
614
647
  for s in resolved:
@@ -616,6 +649,8 @@ def create_session_service(options=None):
616
649
  rows.append({
617
650
  "session_name": s["name"],
618
651
  "provider": s["provider"],
652
+ "enabled": s.get("enabled", True) is not False,
653
+ "status": "disabled" if s.get("enabled", True) is False else "enabled",
619
654
  "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
620
655
  "remaining_week_pct": status.get("remaining_week_pct") if status else None,
621
656
  "credits": status.get("credits") if status else None,
@@ -631,9 +666,18 @@ def create_session_service(options=None):
631
666
  sessions = list_sessions()
632
667
  providers = {s["provider"] for s in sessions}
633
668
  has_multiple = len(providers) > 1
669
+ sessions = sorted(
670
+ sessions,
671
+ key=lambda s: (
672
+ 1 if s.get("enabled", True) is False else 0,
673
+ s.get("name", ""),
674
+ ),
675
+ )
634
676
  return [{
635
677
  "name": s["name"],
636
678
  "provider": s["provider"] if has_multiple else None,
679
+ "enabled": s.get("enabled", True) is not False,
680
+ "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
637
681
  "status": s.get("lastStatus"),
638
682
  "updated_at": _to_local_iso(s.get("updatedAt")),
639
683
  } for s in sessions]
@@ -723,6 +767,7 @@ def create_session_service(options=None):
723
767
  session_record = {
724
768
  **session_payload,
725
769
  "provider": provider,
770
+ "enabled": session_payload.get("enabled", True) is not False,
726
771
  "sessionRoot": session_root,
727
772
  "authHome": auth_home,
728
773
  }
@@ -763,6 +808,7 @@ def create_session_service(options=None):
763
808
  "ensure_session_state": ensure_session_state,
764
809
  "list_sessions": list_sessions,
765
810
  "get_session": get_session,
811
+ "set_session_enabled": set_session_enabled,
766
812
  "record_status": record_status,
767
813
  "update_auth_state": update_auth_state,
768
814
  "get_status_rows": get_status_rows,
@@ -55,22 +55,29 @@ def _style_reset_time(value, use_color=False):
55
55
  def _format_status_rows(rows, use_color=False, small=False):
56
56
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
57
57
  if small:
58
- headers = ["SESSION", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
58
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
59
59
  elif has_provider:
60
- headers = ["SESSION", "PROV.", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
60
+ headers = ["SESSION", "PROV.", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
61
61
  else:
62
- headers = ["SESSION", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
62
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
63
63
  if not rows:
64
64
  if small:
65
- return "SESSION OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
66
- return "SESSION OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
65
+ return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
66
+ return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
67
67
  headers = [_style(header, "1", use_color) for header in headers]
68
- priority = _recommend_priority_sessions(rows)
68
+ active_rows = [r for r in rows if r.get("enabled", True) is not False]
69
+ disabled_rows = sorted(
70
+ [r for r in rows if r.get("enabled", True) is False],
71
+ key=lambda r: r.get("session_name") or "",
72
+ )
73
+ priority = _recommend_priority_sessions(active_rows)
69
74
  table_rows = []
70
- for r in priority:
75
+ for r in priority + disabled_rows:
71
76
  base = [r["session_name"]]
72
77
  if has_provider:
73
78
  base.append(r.get("provider") or "n/a")
79
+ status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
80
+ base.append(_style(status, "2" if status == "disabled" else "32", use_color))
74
81
  usage_columns = [
75
82
  _style_pct(r.get("available_pct"), use_color),
76
83
  _style_pct(r.get("remaining_5h_pct"), use_color),
@@ -101,7 +108,7 @@ def _format_status_rows(rows, use_color=False, small=False):
101
108
  _pad_table([headers] + table_rows),
102
109
  "",
103
110
  _style(priority_line, "1", use_color),
104
- _style("Tip: run /status in codex to refresh. Claude sessions auto-refresh; use --refresh to force.", "2", use_color),
111
+ _style("Tip: Codex status uses the local app-server rate-limit API when available; Claude sessions auto-refresh, use --refresh to force.", "2", use_color),
105
112
  ])
106
113
 
107
114
 
@@ -258,6 +265,7 @@ def _format_status_detail(row, use_color=False):
258
265
  lines = [
259
266
  f"{_style('Session:', '1', use_color)} {row['session_name']}",
260
267
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
268
+ 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)}",
261
269
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
262
270
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
263
271
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",