cdx-manager 0.4.4 → 0.5.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.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.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
 
@@ -36,6 +36,8 @@ 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
+ - **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.
39
41
  - **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.
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.
@@ -52,6 +54,7 @@ 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:
@@ -122,7 +125,7 @@ For a specific version:
122
125
 
123
126
  ```bash
124
127
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
125
- CDX_VERSION=v0.4.4 sh install.sh
128
+ CDX_VERSION=v0.5.0 sh install.sh
126
129
  ```
127
130
 
128
131
  From source:
@@ -242,6 +245,10 @@ cdx status
242
245
  | `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
243
246
  | `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
244
247
  | `cdx logout <name> [--json]` | Log out of a session |
248
+ | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
249
+ | `cdx enable <name> [--json]` | Re-enable a disabled session |
250
+ | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
251
+ | `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
245
252
  | `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
246
253
  | `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
247
254
  | `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 +284,10 @@ Commands with machine-readable output:
277
284
  - `cdx import ... --json`
278
285
  - `cdx login ... --json`
279
286
  - `cdx logout ... --json`
287
+ - `cdx disable ... --json`
288
+ - `cdx enable ... --json`
289
+ - `cdx context ... --json`
290
+ - `cdx handoff ... --json`
280
291
  - `cdx doctor --json`
281
292
  - `cdx repair --json`
282
293
  - `cdx update --json`
@@ -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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
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.0"
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.0"
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
@@ -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,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
+ }
@@ -19,10 +19,14 @@ MAX_SESSION_NAME_LENGTH = 64
19
19
  RESERVED_SESSION_NAMES = {
20
20
  "add",
21
21
  "clean",
22
+ "context",
22
23
  "cp",
24
+ "disable",
23
25
  "doctor",
26
+ "enable",
24
27
  "export",
25
28
  "help",
29
+ "handoff",
26
30
  "import",
27
31
  "login",
28
32
  "logout",
@@ -273,6 +277,7 @@ def create_session_service(options=None):
273
277
  return {
274
278
  "name": session["name"],
275
279
  "provider": session["provider"],
280
+ "enabled": session.get("enabled", True) is not False,
276
281
  "createdAt": session.get("createdAt"),
277
282
  "updatedAt": session.get("updatedAt"),
278
283
  "lastLaunchedAt": session.get("lastLaunchedAt"),
@@ -325,6 +330,7 @@ def create_session_service(options=None):
325
330
  session = {
326
331
  "name": name,
327
332
  "provider": normalized_provider,
333
+ "enabled": True,
328
334
  "sessionRoot": session_root,
329
335
  "authHome": auth_home,
330
336
  "createdAt": now,
@@ -399,6 +405,7 @@ def create_session_service(options=None):
399
405
  replacement = {
400
406
  "name": dest_name,
401
407
  "provider": source["provider"],
408
+ "enabled": True,
402
409
  "sessionRoot": dest_root,
403
410
  "authHome": dest_auth_home,
404
411
  "createdAt": now,
@@ -485,6 +492,8 @@ def create_session_service(options=None):
485
492
  session = store["get_session"](name)
486
493
  if not session:
487
494
  raise CdxError(f"Unknown session: {name}")
495
+ if session.get("enabled", True) is False:
496
+ raise CdxError(f"Session is disabled: {name}")
488
497
  state = store["read_session_state"](name)
489
498
  if not state:
490
499
  raise CdxError(f"Session state missing for {name}. Reconnect required.")
@@ -515,6 +524,17 @@ def create_session_service(options=None):
515
524
  def get_session(name):
516
525
  return store["get_session"](name)
517
526
 
527
+ def set_session_enabled(name, enabled):
528
+ session = store["get_session"](name)
529
+ if not session:
530
+ raise CdxError(f"Unknown session: {name}")
531
+ now = _local_now_iso()
532
+ return store["update_session"](name, lambda s: {
533
+ **s,
534
+ "enabled": bool(enabled),
535
+ "updatedAt": now,
536
+ })
537
+
518
538
  def record_status(name, payload):
519
539
  normalized = _normalize_status_payload(payload)
520
540
  updated = store["update_session"](name, lambda s: {
@@ -605,10 +625,15 @@ def create_session_service(options=None):
605
625
 
606
626
  def sort_key(s):
607
627
  at = s.get("lastStatusAt") or ""
608
- return ("" if at else "\xff", at, s["name"])
628
+ disabled_rank = 1 if s.get("enabled", True) is False else 0
629
+ return (disabled_rank, "" if at else "\xff", at, s["name"])
609
630
 
610
631
  resolved.sort(key=sort_key)
611
- resolved.reverse()
632
+ enabled = [s for s in resolved if s.get("enabled", True) is not False]
633
+ disabled = [s for s in resolved if s.get("enabled", True) is False]
634
+ enabled.reverse()
635
+ disabled.sort(key=lambda s: s["name"])
636
+ resolved = enabled + disabled
612
637
 
613
638
  rows = []
614
639
  for s in resolved:
@@ -616,6 +641,8 @@ def create_session_service(options=None):
616
641
  rows.append({
617
642
  "session_name": s["name"],
618
643
  "provider": s["provider"],
644
+ "enabled": s.get("enabled", True) is not False,
645
+ "status": "disabled" if s.get("enabled", True) is False else "enabled",
619
646
  "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
620
647
  "remaining_week_pct": status.get("remaining_week_pct") if status else None,
621
648
  "credits": status.get("credits") if status else None,
@@ -631,9 +658,18 @@ def create_session_service(options=None):
631
658
  sessions = list_sessions()
632
659
  providers = {s["provider"] for s in sessions}
633
660
  has_multiple = len(providers) > 1
661
+ sessions = sorted(
662
+ sessions,
663
+ key=lambda s: (
664
+ 1 if s.get("enabled", True) is False else 0,
665
+ s.get("name", ""),
666
+ ),
667
+ )
634
668
  return [{
635
669
  "name": s["name"],
636
670
  "provider": s["provider"] if has_multiple else None,
671
+ "enabled": s.get("enabled", True) is not False,
672
+ "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
637
673
  "status": s.get("lastStatus"),
638
674
  "updated_at": _to_local_iso(s.get("updatedAt")),
639
675
  } for s in sessions]
@@ -723,6 +759,7 @@ def create_session_service(options=None):
723
759
  session_record = {
724
760
  **session_payload,
725
761
  "provider": provider,
762
+ "enabled": session_payload.get("enabled", True) is not False,
726
763
  "sessionRoot": session_root,
727
764
  "authHome": auth_home,
728
765
  }
@@ -763,6 +800,7 @@ def create_session_service(options=None):
763
800
  "ensure_session_state": ensure_session_state,
764
801
  "list_sessions": list_sessions,
765
802
  "get_session": get_session,
803
+ "set_session_enabled": set_session_enabled,
766
804
  "record_status": record_status,
767
805
  "update_auth_state": update_auth_state,
768
806
  "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),
@@ -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)}",