cdx-manager 0.11.0 → 0.11.2

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.11.0-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.11.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -36,6 +36,7 @@ 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
  - **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
38
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.
39
+ - **Optional session labels.** `cdx label <name> <label>` adds a short human label; list and full status tables show a `LABEL` column only when at least one session has one.
39
40
  - **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
41
  - **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately.
41
42
  - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
@@ -345,6 +346,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
345
346
  | `cdx add [provider] <name> [--model MODEL] [--json]` | Register a new session (`provider`: `codex`, `claude`, `antigravity`, or `ollama`; Ollama requires `--model`) |
346
347
  | `cdx cp <source> <dest> [--json]` | Copy a session into another session name, overwriting the destination if it exists |
347
348
  | `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
349
+ | `cdx label <name> <label> [--json]` | Attach one short label to a session; tables show `LABEL` only when at least one label exists |
350
+ | `cdx label <name> --clear [--json]` | Clear a session label |
348
351
  | `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
349
352
  | `cdx logout <name> [--json]` | Log out of a session |
350
353
  | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
@@ -399,6 +402,7 @@ Commands with machine-readable output:
399
402
  - `cdx add ... --json`
400
403
  - `cdx cp ... --json`
401
404
  - `cdx ren ... --json`
405
+ - `cdx label ... --json`
402
406
  - `cdx rmv ... --json`
403
407
  - `cdx clean ... --json`
404
408
  - `cdx disk ... --json`
@@ -679,7 +683,7 @@ Without `--yes`, an interactive confirmation is required. `cdx` sends an idempot
679
683
  ## Troubleshooting
680
684
 
681
685
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
682
- - **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file` and `codex_live_auth` checks. Codex sessions use isolated `CODEX_HOME` directories, but newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
686
+ - **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
683
687
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
684
688
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
685
689
  - **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.
@@ -0,0 +1,34 @@
1
+ # CDX Manager 0.11.1
2
+
3
+ ## Highlights
4
+
5
+ - Added optional labels for saved sessions.
6
+ - Kept existing list and compact status output clean when no labels are present.
7
+ - Closed the session-label Logics corpus with full validation evidence.
8
+
9
+ ## Changes
10
+
11
+ ### Optional session labels
12
+
13
+ `cdx label <name> <label>` now attaches one short human-readable label to a saved session. Labels are stored as session metadata outside the `launch` dictionary, so they do not affect provider commands, auth checks, runtime state, ready/next selection, or launch history behavior.
14
+
15
+ `cdx label <name> --clear` removes the label and returns the session to the same shape as legacy unlabeled records.
16
+
17
+ The default `cdx` list and full `cdx status` table add a `LABEL` column only when at least one row has a label. Unlabeled rows render as `-`, and `cdx status --small` remains compact.
18
+
19
+ JSON outputs for `cdx --json`, `cdx status --json`, `cdx status <name> --json`, and `cdx label ... --json` expose label values for automation consumers.
20
+
21
+ Session copy, rename, export, and import preserve labels.
22
+
23
+ ### Documentation and workflow
24
+
25
+ README and CLI help now document `cdx label <name> <label>` and `cdx label <name> --clear`.
26
+
27
+ The optional session-label Logics request, backlog item, product brief, and orchestration task are closed with validation evidence.
28
+
29
+ ## Validation
30
+
31
+ - `python3 -m unittest discover -s test`
32
+ - `logics-manager flow validate req_012_add_optional_labels_to_cdx_sessions`
33
+ - `logics-manager status`
34
+ - `logics-manager health`
@@ -0,0 +1,27 @@
1
+ # CDX Manager 0.11.2
2
+
3
+ ## Highlights
4
+
5
+ - Improved Codex Business auth diagnostics without changing login or launch behavior.
6
+ - Added stale-auth log warnings to help repair the affected profile only.
7
+ - Documented the Business `account_id` ambiguity and the `cdx login <name>` repair path.
8
+
9
+ ## Changes
10
+
11
+ ### Codex Business auth diagnostics
12
+
13
+ `cdx doctor` now reports shared Codex `tokens.account_id` values as workspace-level evidence on Business plans, not proof that two profiles are using the same user account. When recent `/status` transcript output exposes different observed users for profiles that share the same account id, doctor marks that as expected instead of a duplicate-user warning.
14
+
15
+ Codex auth diagnostics now include a masked `account_id` and an observed account from recent logs when available. Raw tokens remain excluded from JSON output.
16
+
17
+ ### Stale auth hints
18
+
19
+ `cdx doctor` scans recent Codex profile logs for common expired-auth markers such as `401`, `token_expired`, and `authentication token is expired`. When found, it emits `codex_stale_auth_logs` with a direct `cdx login <name>` recommendation for the affected isolated profile.
20
+
21
+ ## Validation
22
+
23
+ - `npm run lint`
24
+ - `npm test`
25
+ - `logics-manager health`
26
+ - `logics-manager lint --require-status`
27
+ - `logics-manager audit --group-by-doc`
@@ -136,6 +136,14 @@
136
136
  "v0.10.0": {
137
137
  "github_tarball_sha256": "20c473f4b17806d565c454ad4aa28758433665d8431251ebb937ab6a909e4adb",
138
138
  "github_zip_sha256": "b6a7be359d8dc2a11d8c77749878875419fabd7cd5a6951502dec09d7791e211"
139
+ },
140
+ "v0.11.0": {
141
+ "github_tarball_sha256": "bc42f83f20ba1c6ff81f824c382801457f93cf8cb166acbe952c12d009560a70",
142
+ "github_zip_sha256": "1c03b4f03f53c46d0a5ba580fc8ade7b26fa3cb1bb5f029f703681f136b9ac1f"
143
+ },
144
+ "v0.11.1": {
145
+ "github_tarball_sha256": "33ee5a76cd217007468ea620fa725644dfd62179585551b5703129c00a5094d5",
146
+ "github_zip_sha256": "8b53832ad2af109c22a898782bd36e1408356fc2bd0c3f99ca2ffb1262e2325b"
139
147
  }
140
148
  }
141
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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.11.0"
7
+ version = "0.11.2"
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
@@ -26,6 +26,7 @@ from .cli_commands import (
26
26
  handle_handoff,
27
27
  handle_history,
28
28
  handle_import,
29
+ handle_label,
29
30
  handle_last,
30
31
  handle_launch,
31
32
  handle_launch_setting_alias,
@@ -76,7 +77,7 @@ from .status_view import (
76
77
  )
77
78
  from .update_check import check_for_update, check_logics_manager_for_update
78
79
 
79
- VERSION = "0.11.0"
80
+ VERSION = "0.11.2"
80
81
 
81
82
  # Public surface: this module is a facade. Names below are imported above
82
83
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -112,6 +113,7 @@ _COMMAND_HANDLERS = {
112
113
  "handoff": handle_handoff,
113
114
  "history": handle_history,
114
115
  "import": handle_import,
116
+ "label": handle_label,
115
117
  "last": handle_last,
116
118
  "login": handle_login,
117
119
  "logout": handle_logout,
@@ -200,6 +202,8 @@ def _print_help(use_color=False):
200
202
  f" {_style('cdx add [provider] <name> [--model MODEL] [--json]', '36', use_color)}",
201
203
  f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
202
204
  f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
205
+ f" {_style('cdx label <name> <label> [--json]', '36', use_color)}",
206
+ f" {_style('cdx label <name> --clear [--json]', '36', use_color)}",
203
207
  f" {_style('cdx login <name> [--json]', '36', use_color)}",
204
208
  f" {_style('cdx logout <name> [--json]', '36', use_color)}",
205
209
  f" {_style('cdx disable <name> [--json]', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -20,6 +20,7 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
20
20
  IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
21
21
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
22
22
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
23
+ LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
23
24
  SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
24
25
  UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]"
25
26
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
@@ -107,6 +108,21 @@ def _parse_rename_args(args):
107
108
  return {"source": args[0], "dest": args[1]}
108
109
 
109
110
 
111
+ def _parse_label_args(args):
112
+ parsed = _parse_flag_args(args, {
113
+ "--clear": {"key": "clear", "type": "bool", "default": False},
114
+ "--json": {"key": "json", "type": "bool", "default": False},
115
+ }, LABEL_USAGE, positionals_key="values", max_positionals=2)
116
+ values = parsed["values"]
117
+ if parsed["clear"]:
118
+ if len(values) != 1:
119
+ raise CdxError(LABEL_USAGE)
120
+ return {"name": values[0], "label": None, "clear": True, "json": parsed["json"]}
121
+ if len(values) != 2:
122
+ raise CdxError(LABEL_USAGE)
123
+ return {"name": values[0], "label": values[1], "clear": False, "json": parsed["json"]}
124
+
125
+
110
126
  def _parse_remove_args(args):
111
127
  parsed = _parse_flag_args(args, {
112
128
  "--force": {"key": "force", "type": "bool", "default": False},
@@ -37,6 +37,7 @@ from .cli_args import (
37
37
  _parse_history_args,
38
38
  _parse_import_args,
39
39
  _parse_json_flag,
40
+ _parse_label_args,
40
41
  _parse_launch_setting_alias_args,
41
42
  _parse_next_args,
42
43
  _parse_remove_args,
@@ -732,6 +733,21 @@ def handle_rename(rest, ctx):
732
733
  return 0
733
734
 
734
735
 
736
+ def handle_label(rest, ctx):
737
+ parsed = _parse_label_args(rest)
738
+ if parsed["clear"]:
739
+ session = ctx["service"]["clear_session_label"](parsed["name"])
740
+ message = f"Cleared label for {parsed['name']}"
741
+ else:
742
+ session = ctx["service"]["set_session_label"](parsed["name"], parsed["label"])
743
+ message = f"Set label for {parsed['name']}: {session['label']}"
744
+ if parsed["json"]:
745
+ _write_json(ctx, _json_success("label", message, session=session, label=session.get("label")))
746
+ return 0
747
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
748
+ return 0
749
+
750
+
735
751
  def handle_remove(rest, ctx):
736
752
  json_flag, args = _parse_json_flag(rest)
737
753
  parsed = _parse_remove_args(args)
package/src/cli_render.py CHANGED
@@ -123,10 +123,13 @@ def format_error(error, env=None, stderr=None):
123
123
  def _format_sessions(service, use_color=False):
124
124
  rows = service["format_list_rows"]()
125
125
  has_provider = any(r.get("provider") for r in rows)
126
+ has_label = any(r.get("label") for r in rows)
126
127
  has_launch = any(r.get("launch") for r in rows)
127
128
  headers = ["SESSION"]
128
129
  if has_provider:
129
130
  headers.append("PROVIDER")
131
+ if has_label:
132
+ headers.append("LABEL")
130
133
  headers.append("STATUS")
131
134
  if has_launch:
132
135
  headers.append("LAUNCH")
@@ -138,6 +141,8 @@ def _format_sessions(service, use_color=False):
138
141
  parts = [name]
139
142
  if has_provider:
140
143
  parts.append(r.get("provider") or "n/a")
144
+ if has_label:
145
+ parts.append(r.get("label") or "-")
141
146
  status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
142
147
  parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
143
148
  if has_launch:
package/src/health.py CHANGED
@@ -3,11 +3,13 @@ import os
3
3
  import shutil
4
4
  import sys
5
5
  import tempfile
6
+ from datetime import datetime, timezone
6
7
  from urllib.parse import quote, unquote
7
8
 
8
9
  from .cli_render import _pad_table, _style
9
10
  from .config import PROVIDER_CODEX
10
11
  from .provider_runtime import codex_auth_diagnostic
12
+ from .status_source import _extract_account_identity
11
13
 
12
14
 
13
15
  def _encode(name):
@@ -73,6 +75,7 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
73
75
  issues.append(_check_cdx_home(base_dir))
74
76
  sessions = service["list_sessions"]()
75
77
  session_names = {session["name"] for session in sessions}
78
+ codex_diagnostics = []
76
79
  for session in sessions:
77
80
  name = session["name"]
78
81
  root = session.get("sessionRoot") or service["get_session_root"](name)
@@ -82,16 +85,19 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
82
85
  if not os.path.isfile(state_path):
83
86
  issues.append(_issue("FAIL", "missing_state", f"session {name} state file is missing", state_path, True))
84
87
  if session.get("provider") == PROVIDER_CODEX:
85
- issues.extend(_codex_auth_issues(session, spawn_sync=spawn_sync, env=env))
88
+ diag = codex_auth_diagnostic(session, spawn_sync=spawn_sync, env_override=env)
89
+ diag["observed_account"] = _recent_codex_observed_account(diag.get("auth_home"))
90
+ codex_diagnostics.append((session, diag))
91
+ issues.extend(_codex_auth_issues(session, diag))
86
92
 
93
+ issues.extend(_codex_shared_account_id_issues(codex_diagnostics))
87
94
  issues.extend(_collect_profile_issues(base_dir, session_names))
88
95
  return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
89
96
 
90
97
 
91
- def _codex_auth_issues(session, spawn_sync=None, env=None):
98
+ def _codex_auth_issues(session, diag):
92
99
  name = session["name"]
93
- diag = codex_auth_diagnostic(session, spawn_sync=spawn_sync, env_override=env)
94
- identity = diag.get("account_email") or "unknown account"
100
+ identity = diag.get("account_email") or diag.get("observed_account") or "unknown account"
95
101
  issues = [
96
102
  _issue(
97
103
  "OK" if diag.get("auth_json_exists") else "WARN",
@@ -103,6 +109,8 @@ def _codex_auth_issues(session, spawn_sync=None, env=None):
103
109
  "auth_json_exists": diag.get("auth_json_exists"),
104
110
  "local_tokens_present": diag.get("local_tokens_present"),
105
111
  "account_email": diag.get("account_email"),
112
+ "observed_account": diag.get("observed_account"),
113
+ "account_id": _mask_identifier(diag.get("account_id")),
106
114
  },
107
115
  )
108
116
  ]
@@ -120,9 +128,126 @@ def _codex_auth_issues(session, spawn_sync=None, env=None):
120
128
  "live_error": diag.get("live_error"),
121
129
  },
122
130
  ))
131
+ stale_auth = _recent_codex_auth_error(diag.get("auth_home"))
132
+ if stale_auth:
133
+ issues.append(_issue(
134
+ "WARN",
135
+ "codex_stale_auth_logs",
136
+ f"session {name} recent Codex logs mention expired auth; run cdx login {name} if disconnects continue",
137
+ {
138
+ "session": name,
139
+ "auth_home": diag.get("auth_home"),
140
+ "source": stale_auth["source"],
141
+ "mtime": stale_auth["mtime"],
142
+ "markers": stale_auth["markers"],
143
+ },
144
+ ))
123
145
  return issues
124
146
 
125
147
 
148
+ def _codex_shared_account_id_issues(codex_diagnostics):
149
+ by_account_id = {}
150
+ for session, diag in codex_diagnostics:
151
+ account_id = diag.get("account_id")
152
+ if account_id:
153
+ by_account_id.setdefault(account_id, []).append((session, diag))
154
+
155
+ issues = []
156
+ for account_id, rows in sorted(by_account_id.items()):
157
+ if len(rows) < 2:
158
+ continue
159
+ identities = sorted({
160
+ _identity_user_part(diag.get("account_email") or diag.get("observed_account"))
161
+ for _session, diag in rows
162
+ if _identity_user_part(diag.get("account_email") or diag.get("observed_account"))
163
+ })
164
+ sessions = [session["name"] for session, _diag in rows]
165
+ status = "OK" if len(identities) > 1 else "WARN"
166
+ message = (
167
+ "Codex profiles share a Business account_id but observed different users; account_id is not a user identity"
168
+ if status == "OK"
169
+ else "Codex profiles share account_id; on Business plans this may be a workspace id, not proof of duplicate user auth"
170
+ )
171
+ issues.append(_issue(
172
+ status,
173
+ "codex_shared_account_id",
174
+ message,
175
+ {
176
+ "sessions": sessions,
177
+ "account_id": _mask_identifier(account_id),
178
+ "observed_identities": identities,
179
+ },
180
+ ))
181
+ return issues
182
+
183
+
184
+ def _identity_user_part(value):
185
+ if not value:
186
+ return None
187
+ return str(value).strip().lower().split()[0].split("(", 1)[0]
188
+
189
+
190
+ def _mask_identifier(value):
191
+ if not value:
192
+ return None
193
+ text = str(value)
194
+ return text if len(text) <= 8 else f"{text[:6]}...{text[-4:]}"
195
+
196
+
197
+ def _recent_codex_observed_account(auth_home):
198
+ for path in _recent_codex_log_paths(auth_home):
199
+ text = _read_tail(path)
200
+ account = _extract_account_identity(text)
201
+ if account:
202
+ return account
203
+ return None
204
+
205
+
206
+ def _recent_codex_auth_error(auth_home):
207
+ markers = ("token_expired", "authentication token is expired", "http 401")
208
+ for path in _recent_codex_log_paths(auth_home):
209
+ text = _read_tail(path).lower()
210
+ found = [marker for marker in markers if marker in text]
211
+ if found:
212
+ try:
213
+ mtime = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
214
+ except OSError:
215
+ mtime = None
216
+ return {"source": path, "mtime": mtime, "markers": found}
217
+ return None
218
+
219
+
220
+ def _recent_codex_log_paths(auth_home):
221
+ if not auth_home:
222
+ return []
223
+ candidates = []
224
+ for root in (os.path.join(auth_home, "log"), os.path.join(auth_home, "sessions")):
225
+ if not os.path.isdir(root):
226
+ continue
227
+ for dirpath, dirnames, filenames in os.walk(root):
228
+ dirnames[:] = [name for name in dirnames if name not in {"cache", "plugins", "skills", "sqlite"}]
229
+ for filename in filenames:
230
+ if filename.endswith((".log", ".jsonl")):
231
+ path = os.path.join(dirpath, filename)
232
+ try:
233
+ stat = os.stat(path)
234
+ except OSError:
235
+ continue
236
+ candidates.append((stat.st_mtime, path))
237
+ return [path for _mtime, path in sorted(candidates, reverse=True)[:16]]
238
+
239
+
240
+ def _read_tail(path, max_bytes=128 * 1024):
241
+ try:
242
+ with open(path, "rb") as handle:
243
+ handle.seek(0, os.SEEK_END)
244
+ size = handle.tell()
245
+ handle.seek(max(0, size - max_bytes))
246
+ return handle.read().decode("utf-8", errors="replace")
247
+ except OSError:
248
+ return ""
249
+
250
+
126
251
  def _check_cdx_home(base_dir):
127
252
  try:
128
253
  os.makedirs(base_dir, exist_ok=True)
@@ -166,6 +166,19 @@ def _read_codex_account_email(auth_home):
166
166
  return None
167
167
 
168
168
 
169
+ def _read_codex_account_id(auth_home):
170
+ try:
171
+ with open(os.path.join(auth_home, "auth.json"), encoding="utf-8") as handle:
172
+ auth = json.load(handle)
173
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
174
+ return None
175
+ tokens = auth.get("tokens") if isinstance(auth, dict) else {}
176
+ account_id = tokens.get("account_id") if isinstance(tokens, dict) else None
177
+ if not account_id:
178
+ return None
179
+ return str(account_id).strip() or None
180
+
181
+
169
182
  def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
170
183
  auth_home = _get_auth_home(session)
171
184
  auth_path = os.path.join(auth_home, "auth.json")
@@ -174,6 +187,7 @@ def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
174
187
  "auth_json_exists": os.path.isfile(auth_path),
175
188
  "local_tokens_present": _has_local_codex_auth(auth_home),
176
189
  "account_email": _read_codex_account_email(auth_home),
190
+ "account_id": _read_codex_account_id(auth_home),
177
191
  "live_status": "unknown",
178
192
  "live_error": None,
179
193
  }
@@ -22,6 +22,7 @@ from .status_source import find_latest_status_artifact
22
22
  DEFAULT_PROVIDER = PROVIDER_CODEX
23
23
  ALLOWED_PROVIDERS = set(PROVIDERS)
24
24
  MAX_SESSION_NAME_LENGTH = 64
25
+ MAX_SESSION_LABEL_LENGTH = 64
25
26
  RESERVED_SESSION_NAMES = {
26
27
  "add",
27
28
  "can-resume",
@@ -40,6 +41,7 @@ RESERVED_SESSION_NAMES = {
40
41
  "history",
41
42
  "import",
42
43
  "last",
44
+ "label",
43
45
  "login",
44
46
  "logout",
45
47
  "model",
@@ -534,10 +536,21 @@ def create_session_service(options=None):
534
536
  if name in RESERVED_SESSION_NAMES:
535
537
  raise CdxError(f"Session name is reserved: {name}")
536
538
 
539
+ def _normalize_session_label(label):
540
+ text = str(label or "").strip()
541
+ if not text:
542
+ raise CdxError("Session label is required")
543
+ if len(text) > MAX_SESSION_LABEL_LENGTH:
544
+ raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
545
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
546
+ raise CdxError("Session label cannot contain control characters")
547
+ return text
548
+
537
549
  def _build_export_session_record(session):
538
550
  return {
539
551
  "name": session["name"],
540
552
  "provider": session["provider"],
553
+ "label": session.get("label"),
541
554
  "enabled": session.get("enabled", True) is not False,
542
555
  "createdAt": session.get("createdAt"),
543
556
  "updatedAt": session.get("updatedAt"),
@@ -698,6 +711,7 @@ def create_session_service(options=None):
698
711
  "lastStatusAt": None,
699
712
  "lastStatus": None,
700
713
  **({"launch": source.get("launch")} if source.get("launch") else {}),
714
+ **({"label": source.get("label")} if source.get("label") else {}),
701
715
  "auth": {
702
716
  "status": "unknown",
703
717
  "lastCheckedAt": None,
@@ -888,6 +902,31 @@ def create_session_service(options=None):
888
902
  "updatedAt": now,
889
903
  })
890
904
 
905
+ def set_session_label(name, label):
906
+ session = store["get_session"](name)
907
+ if not session:
908
+ raise CdxError(f"Unknown session: {name}")
909
+ normalized = _normalize_session_label(label)
910
+ now = _local_now_iso()
911
+ return store["update_session"](name, lambda s: {
912
+ **s,
913
+ "label": normalized,
914
+ "updatedAt": now,
915
+ })
916
+
917
+ def clear_session_label(name):
918
+ session = store["get_session"](name)
919
+ if not session:
920
+ raise CdxError(f"Unknown session: {name}")
921
+ now = _local_now_iso()
922
+
923
+ def updater(s):
924
+ updated = {**s, "updatedAt": now}
925
+ updated.pop("label", None)
926
+ return updated
927
+
928
+ return store["update_session"](name, updater)
929
+
891
930
  def set_launch_settings(name, settings):
892
931
  session = store["get_session"](name)
893
932
  if not session:
@@ -1106,6 +1145,7 @@ def create_session_service(options=None):
1106
1145
  row_status = status if enabled else None
1107
1146
  return {
1108
1147
  "session_name": s["name"],
1148
+ "label": s.get("label"),
1109
1149
  "provider": s["provider"],
1110
1150
  "enabled": enabled,
1111
1151
  "active": bool(_session_runtime(s["name"])) if enabled else False,
@@ -1273,6 +1313,7 @@ def create_session_service(options=None):
1273
1313
  )
1274
1314
  return [{
1275
1315
  "name": s["name"],
1316
+ "label": s.get("label"),
1276
1317
  "provider": s["provider"] if has_multiple else None,
1277
1318
  "enabled": s.get("enabled", True) is not False,
1278
1319
  "active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
@@ -1519,6 +1560,8 @@ def create_session_service(options=None):
1519
1560
  "list_sessions": list_sessions,
1520
1561
  "get_session": get_session,
1521
1562
  "set_session_enabled": set_session_enabled,
1563
+ "set_session_label": set_session_label,
1564
+ "clear_session_label": clear_session_label,
1522
1565
  "set_launch_settings": set_launch_settings,
1523
1566
  "unset_launch_settings": unset_launch_settings,
1524
1567
  "record_status": record_status,
@@ -75,12 +75,19 @@ def _format_credits(value, empty="n/a"):
75
75
 
76
76
  def _format_status_rows(rows, use_color=False, small=False):
77
77
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
78
+ has_label = any(r.get("label") for r in rows) and not small
78
79
  if small:
79
80
  headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESETS", "RESET 5H", "RESET WEEK"]
80
81
  elif has_provider:
81
- headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
82
+ headers = ["SESSION", "PROV."]
83
+ if has_label:
84
+ headers.append("LABEL")
85
+ headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
82
86
  else:
83
- headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
87
+ headers = ["SESSION"]
88
+ if has_label:
89
+ headers.append("LABEL")
90
+ headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
84
91
  if not rows:
85
92
  if small:
86
93
  return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
@@ -102,6 +109,8 @@ def _format_status_rows(rows, use_color=False, small=False):
102
109
  base = [_format_session_name(r)]
103
110
  if has_provider:
104
111
  base.append(r.get("provider") or "n/a")
112
+ if has_label:
113
+ base.append(r.get("label") or "-")
105
114
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
106
115
  base.append(_style(status, "2" if status == "disabled" else "32", use_color))
107
116
  if not small: