cdx-manager 0.21.4 → 0.21.5

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.21.4
1
+ 0.21.5
@@ -0,0 +1,26 @@
1
+ # CDX Manager 0.21.5
2
+
3
+ ## Claude logins
4
+
5
+ - `cdx status` shows each Claude profile's auth type in a TYPE column: `setup-token` or `full-login` (a `/login` run inside the profile). It also appears in the single-session view and as `auth_type` in `--json`. Within 30 days of a setup-token's one-year expiry, the column shows `setup-token 12d`, or `setup-token expired` once it has lapsed, and `cdx doctor` warns with the `cdx login` command to run. Based on work by Nicolas Rigaudy (#15, #16, #21).
6
+ - The operations guide explains why a setup-token never loads claude.ai connectors such as Microsoft 365, and how to switch with `/login`.
7
+ - On macOS, `cdx login` on a Claude profile now also removes the profile's keychain `/login`, so the new setup-token is the one used at launch. Before, the keychain login kept winning. The misleading "falling back to claude setup-token" message is reworded (#18).
8
+
9
+ ## Fixes
10
+
11
+ - `cdx ready` never showed its notification: the scheduled job ran `cdx notify`, which now only serves agent hooks. On macOS the job was also written under the profile home inside a cdx session, where launchd never reads it (#18, #21).
12
+ - Windows:
13
+ - the session store lock now waits up to 120 seconds instead of failing after 10;
14
+ - the process-alive check no longer sends Ctrl+C to the process it probes (#18).
15
+ - Agent alert hooks work when the cdx path contains a space. Claude's `settings.json` is written atomically, through symlinks, and keeps its permissions (#18).
16
+ - Token prices reviewed on 2026-10-22:
17
+ - `claude-sonnet-5-5` cache reads are $0.10/MTok;
18
+ - `claude-haiku-5-5` is added;
19
+ - context variants such as `claude-opus-5-5[1m]` are priced at the base model's rates (#20).
20
+
21
+ ## Housekeeping
22
+
23
+ - `runs.json` keeps every running run plus the newest 500 finished ones.
24
+ - Handoff entries older than 7 days are deleted when a new one is prepared.
25
+ - Dead log-rotation code and duplicated helpers are removed.
26
+ - New tests cover the state-changing `cdx tray` commands (#17, #19).
@@ -518,6 +518,17 @@
518
518
  "x86_64-pc-windows-msvc": "9729f245b46420ae4b11c1a50d3ba9cba682854866ef37a3f334dccbdd70ac89",
519
519
  "x86_64-unknown-linux-musl": "182fb651dab29a6a21b3b8ef56c196e954fd75230891f8532c21b09ff5fbacee"
520
520
  }
521
+ },
522
+ "v0.21.4": {
523
+ "github_tarball_sha256": "265936ceb35ffd43679e916f7ac2572b73e3a499803b86dfa8e87029a4188394",
524
+ "github_zip_sha256": "f9a648c4cba0d8c3204cac151f695d6fc4f86316d6dbdb6a1e8daf471dcd042b",
525
+ "tray_assets": {
526
+ "aarch64-apple-darwin": "b17aa8631acdcfa3e0de94ef8282b5b35791fd74d1e0be321bad42b20e7450ea",
527
+ "aarch64-unknown-linux-musl": "45eefeeb0d6c71269a24bece05780db98eaaa0e33630366f31cdb006f1dea946",
528
+ "x86_64-apple-darwin": "c10294583e88373570c7c5ad9094b711d57499d73358e9a1fa397bf5b76a12e3",
529
+ "x86_64-pc-windows-msvc": "0d4e6c736a789d8fddaf688cd61d899a305171fa6d7e856735b2cd7d2daef21c",
530
+ "x86_64-unknown-linux-musl": "8320025507801fd549b8c8dd4196a9378ddb25f137e48945e61e429bf2a864aa"
531
+ }
521
532
  }
522
533
  }
523
534
  }
package/docs/cli.md CHANGED
@@ -56,7 +56,7 @@
56
56
  | `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
57
57
  | `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
58
58
  | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike |
59
- | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
59
+ | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status; Claude rows also report the auth type (`setup-token` or `full-login`) in a TYPE column and as `auth_type` in JSON. |
60
60
  | `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
61
61
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
62
62
  | `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
@@ -151,6 +151,7 @@ Hosts keep independent rotating credentials, so copying an auth file is not ongo
151
151
  credential sync.
152
152
 
153
153
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first. `cdx doctor` uses the same Codex app-server probe as `cdx auth refresh`, so a local auth file alone is never reported as live authentication; a rejected refresh token is reported as `login_required` with the matching login command.
154
+ - **A Claude session has no claude.ai connectors (Microsoft 365, ...) in `/mcp`** — `cdx login <name>` stores a setup-token for Claude profiles, and Claude Code can only make model requests with it: it never loads claude.ai connectors. `cdx status` shows each Claude profile's auth type (`setup-token` or `full-login`; `auth_type` in `--json`). To switch, start the session and run `/login` inside it. Once the profile holds its own claude.ai login (its `.claude/.credentials.json`, or its profile-specific `Claude Code-credentials-<hash>` keychain entry on macOS), cdx stops passing the setup-token on later launches. If that login later expires, run `/login` again, or go back to a setup-token with `cdx login <name>`, which removes the profile's `/login`.
154
155
  - **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.
155
156
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
156
157
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.21.4",
3
+ "version": "0.21.5",
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.21.4"
7
+ version = "0.21.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,6 +15,7 @@ import shutil
15
15
  import subprocess
16
16
 
17
17
  from .config import get_cdx_home
18
+ from .fs_utils import atomic_write
18
19
  from .notify import notification_channel, send_desktop_notification
19
20
  from .tray_alerts import alerts_enabled
20
21
  from .tray_events import publish
@@ -71,7 +72,11 @@ def resolve_hook_command(env=None):
71
72
  the provider inherits.
72
73
  """
73
74
  env = env or os.environ
74
- return env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
75
+ path = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
76
+ # Hooks run through a shell, so a space in the path ("C:\\Users\\Jean Dupont",
77
+ # a macOS home with a space) would split it. Double quotes read the same in
78
+ # sh, Git Bash and cmd.exe; a path without whitespace stays as it was.
79
+ return f'"{path}"' if any(ch.isspace() for ch in path) else path
75
80
 
76
81
 
77
82
  def read_hook_payload(args, stdin_text=None):
@@ -547,11 +552,16 @@ def _read_json(path):
547
552
 
548
553
 
549
554
  def _write_json(path, document):
555
+ # Atomic, because Claude Code may rewrite the same settings file at the same
556
+ # moment. Through realpath so a symlinked settings.json stays a symlink, and
557
+ # with the file's own mode so a user-tightened file is not loosened.
550
558
  try:
551
- os.makedirs(os.path.dirname(path), exist_ok=True)
552
- with open(path, "w", encoding="utf-8") as handle:
553
- json.dump(document, handle, indent=2)
554
- handle.write("\n")
559
+ target = os.path.realpath(path)
560
+ try:
561
+ mode = os.stat(target).st_mode & 0o777
562
+ except FileNotFoundError:
563
+ mode = 0o644
564
+ atomic_write(target, json.dumps(document, indent=2) + "\n", mode=mode)
555
565
  except OSError:
556
566
  # An unwritable home is a skipped provisioning step, not a failed launch.
557
567
  return False
@@ -81,6 +81,91 @@ def peek_keychain_credentials(auth_home):
81
81
  return None
82
82
 
83
83
 
84
+ INFERENCE_SCOPE = "user:inference"
85
+
86
+
87
+ def is_full_claude_login(credentials):
88
+ """True for a claude.ai login, False for an inference-only credential.
89
+
90
+ A setup-token can only make model requests, so Claude Code never loads
91
+ claude.ai connectors with it. A real `/login` stores a refresh token and
92
+ scopes beyond inference. Only the shape is inspected, never the values.
93
+ """
94
+ oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
95
+ if not isinstance(oauth, dict) or not oauth.get("accessToken"):
96
+ return False
97
+ scopes = oauth.get("scopes")
98
+ extra_scopes = isinstance(scopes, list) and any(
99
+ isinstance(scope, str) and scope != INFERENCE_SCOPE for scope in scopes
100
+ )
101
+ return bool(oauth.get("refreshToken")) or extra_scopes
102
+
103
+
104
+ def read_claude_login_file(auth_home):
105
+ try:
106
+ with open(os.path.join(auth_home, ".claude", ".credentials.json"), encoding="utf-8") as handle:
107
+ data = json.load(handle)
108
+ except (OSError, ValueError):
109
+ return None
110
+ return data if isinstance(data, dict) else None
111
+
112
+
113
+ def has_full_claude_login(auth_home):
114
+ """Whether the profile holds a claude.ai login: the profile's
115
+ `.credentials.json` (Linux, Windows) or its keychain entry (macOS)."""
116
+ if not auth_home:
117
+ return False
118
+ return is_full_claude_login(read_claude_login_file(auth_home)) or is_full_claude_login(
119
+ peek_keychain_credentials(auth_home)
120
+ )
121
+
122
+
123
+ def claude_auth_type(auth_home):
124
+ """`full-login`, `setup-token`, or None when the profile holds neither."""
125
+ if has_full_claude_login(auth_home):
126
+ return "full-login"
127
+ try:
128
+ with open(os.path.join(auth_home, "credentials", "default.json"), encoding="utf-8") as handle:
129
+ token = json.load(handle).get("access_token")
130
+ except (OSError, ValueError, AttributeError):
131
+ return None
132
+ return "setup-token" if token else None
133
+
134
+
135
+ # `claude setup-token` mints a token valid for one year. Tokens minted before
136
+ # cdx recorded `created_at` fall back to the file's mtime, which a copy or a
137
+ # backup import resets, so their date can read later than it really is.
138
+ SETUP_TOKEN_LIFETIME_SECONDS = 365 * 24 * 3600
139
+ SETUP_TOKEN_WARN_DAYS = 30
140
+
141
+
142
+ def setup_token_expires_at(auth_home):
143
+ """Epoch seconds when the profile's setup-token expires, or None."""
144
+ path = os.path.join(auth_home or "", "credentials", "default.json")
145
+ try:
146
+ with open(path, encoding="utf-8") as handle:
147
+ data = json.load(handle)
148
+ if not isinstance(data, dict) or not data.get("access_token"):
149
+ return None
150
+ created = data.get("created_at")
151
+ if isinstance(created, str):
152
+ from datetime import datetime
153
+ created = datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp()
154
+ elif not isinstance(created, (int, float)):
155
+ created = os.path.getmtime(path)
156
+ except (OSError, ValueError):
157
+ return None
158
+ return created + SETUP_TOKEN_LIFETIME_SECONDS
159
+
160
+
161
+ def setup_token_days_left(auth_home, now=None):
162
+ expires = setup_token_expires_at(auth_home)
163
+ if expires is None:
164
+ return None
165
+ import time
166
+ return int((expires - (time.time() if now is None else now)) // 86400)
167
+
168
+
84
169
  def write_keychain_credentials(auth_home, data):
85
170
  _reject_custom_oauth()
86
171
  user, service = _keychain_identity(auth_home)
package/src/cli.py CHANGED
@@ -72,10 +72,6 @@ from .cli_render import (
72
72
  )
73
73
  from .config import PROVIDER_OLLAMA
74
74
  from .errors import CdxError
75
- from .provider_runtime import (
76
- LOG_ROTATE_BYTES,
77
- _rotate_log_if_needed,
78
- )
79
75
  from .session_service import create_session_service
80
76
  from .status_view import (
81
77
  _format_blocking_quota,
@@ -138,13 +134,11 @@ VERSION = _resolve_version()
138
134
  # them here documents that and stops F401 from treating them as dead imports.
139
135
  __all__ = [
140
136
  "STATUS_USAGE",
141
- "LOG_ROTATE_BYTES",
142
137
  "_format_blocking_quota",
143
138
  "_format_reset_time",
144
139
  "_format_status_detail",
145
140
  "_format_status_rows",
146
141
  "_pad_table",
147
- "_rotate_log_if_needed",
148
142
  "_visible_len",
149
143
  "cli_entry",
150
144
  "format_json_error",
@@ -9,8 +9,9 @@ import asyncio
9
9
  import json
10
10
  import os
11
11
  import re
12
- from datetime import datetime
12
+ from datetime import datetime, timezone
13
13
 
14
+ from .claude_credentials import delete_keychain_credentials
14
15
  from .cli_render import _dim, _info, _pad_table, _style, _warn
15
16
  from .errors import CdxError
16
17
  from .fs_utils import atomic_write
@@ -237,6 +238,8 @@ def _write_claude_oauth_token(auth_home, token):
237
238
  "version": "1.0",
238
239
  "type": "oauth_token",
239
240
  "access_token": token,
241
+ # Read by claude_credentials.setup_token_expires_at for the expiry warning.
242
+ "created_at": datetime.now(timezone.utc).isoformat(),
240
243
  }
241
244
  # atomic_write keeps the token 0o600 from creation — no umask window.
242
245
  atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
@@ -244,7 +247,7 @@ def _write_claude_oauth_token(auth_home, token):
244
247
 
245
248
  def _bootstrap_claude_setup_token(session, ctx):
246
249
  ctx["out"](
247
- "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
250
+ "Minting a long-lived Claude setup-token for this session.\n"
248
251
  )
249
252
  run_info = _run_interactive_provider_command(
250
253
  session,
@@ -277,11 +280,14 @@ def _bootstrap_claude_setup_token(session, ctx):
277
280
  )
278
281
  auth_home = session.get("authHome") or ""
279
282
  cred_path = _write_claude_oauth_token(auth_home, token)
280
- # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
283
+ # A profile /login wins over the setup-token at launch (see
284
+ # provider_runtime._read_claude_launch_oauth_token), so drop it on every
285
+ # platform: the file on Linux/Windows, the profile keychain entry on macOS.
281
286
  try:
282
287
  os.remove(os.path.join(auth_home, ".claude", ".credentials.json"))
283
288
  except OSError:
284
289
  pass
290
+ delete_keychain_credentials(auth_home)
285
291
  return cred_path
286
292
 
287
293
  def _format_launch_config(session, use_color=False):
@@ -100,6 +100,12 @@ def handle_ready(rest, ctx):
100
100
  unreachable once `cdx ready` became the only entry point.
101
101
  """
102
102
  parsed = parse_ready_args(rest)
103
+ if parsed["mode"] == "fire":
104
+ send_desktop_notification(
105
+ "cdx", f"{parsed['fire']} reset time reached; it should be usable again",
106
+ spawn_sync=ctx.get("spawn_sync"), env=ctx.get("env"),
107
+ )
108
+ return 0
103
109
  event = resolve_notify_event(
104
110
  ctx["service"]["get_status_rows"](
105
111
  progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
@@ -685,26 +685,6 @@ def _refuse(ctx, as_json, action, code, message):
685
685
  return 0
686
686
 
687
687
 
688
- def _companion_action(action, args, ctx):
689
- parsed = _parse_flag_args(args, {
690
- "--json": {"key": "json", "type": "bool", "default": False},
691
- }, TRAY_USAGE, positionals_key="args", max_positionals=0)
692
- message = (
693
- f"cdx tray {action} is defined but no tray companion ships with CDX {ctx['version']}. "
694
- "Nothing was installed, started, or changed."
695
- )
696
- if parsed["json"]:
697
- _write_json(ctx, _json_success(
698
- f"tray.{action}",
699
- message,
700
- warnings=[{"code": COMPANION_UNAVAILABLE, "message": message}],
701
- applied=False,
702
- ))
703
- return 0
704
- ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
705
- return 0
706
-
707
-
708
688
  def _pct(value):
709
689
  return "?" if value is None else f"{int(value)}%"
710
690
 
package/src/handoff.py CHANGED
@@ -5,6 +5,7 @@ import hashlib
5
5
  import json
6
6
  import os
7
7
  import re
8
+ import time
8
9
  from datetime import datetime, timezone
9
10
  from uuid import uuid4
10
11
 
@@ -231,9 +232,32 @@ def prepare_entry(base_dir, target, workspace, source=None, transcript=None):
231
232
  except OSError:
232
233
  os.unlink(path)
233
234
  raise
235
+ _drop_old_entries(os.path.dirname(path), keep=path)
234
236
  return path, entry
235
237
 
236
238
 
239
+ # A launch reads its entry right after preparing it, and concurrent prepares
240
+ # each keep their own, so only entries well past any launch are removed.
241
+ HANDOFF_ENTRY_MAX_AGE_SECONDS = 7 * 24 * 3600
242
+
243
+
244
+ def _drop_old_entries(directory, keep, now=None):
245
+ cutoff = (time.time() if now is None else now) - HANDOFF_ENTRY_MAX_AGE_SECONDS
246
+ try:
247
+ names = os.listdir(directory)
248
+ except OSError:
249
+ return
250
+ for name in names:
251
+ candidate = os.path.join(directory, name)
252
+ if not name.endswith('.json') or candidate == keep:
253
+ continue
254
+ try:
255
+ if os.path.getmtime(candidate) < cutoff:
256
+ os.unlink(candidate)
257
+ except OSError:
258
+ pass
259
+
260
+
237
261
  def load_entry(base_dir, target, workspace):
238
262
  """Reuse only this target/workspace's prepared entry; never downgrade stale data."""
239
263
  pointer = _entry_pointer(base_dir, target, workspace)
package/src/health.py CHANGED
@@ -110,6 +110,8 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None, check_pr
110
110
  state_path = _state_file_path(base_dir, name)
111
111
  if not os.path.isfile(state_path):
112
112
  issues.append(_issue("FAIL", "missing_state", f"session {name} state file is missing", state_path, True))
113
+ if session.get("provider") == PROVIDER_CLAUDE:
114
+ issues.extend(_claude_setup_token_issues(session))
113
115
  if session.get("provider") == PROVIDER_CODEX:
114
116
  diag = codex_auth_diagnostic(session, spawn_sync=spawn_sync, env_override=env)
115
117
  diag["observed_account"] = _recent_codex_observed_account(diag.get("auth_home"))
@@ -374,6 +376,22 @@ def _codex_auth_issues(session, diag):
374
376
  return issues
375
377
 
376
378
 
379
+ def _claude_setup_token_issues(session):
380
+ from .claude_credentials import SETUP_TOKEN_WARN_DAYS, claude_auth_type, setup_token_days_left
381
+
382
+ home = session.get("authHome")
383
+ if not home or claude_auth_type(home) != "setup-token":
384
+ return []
385
+ days = setup_token_days_left(home)
386
+ if days is None or days > SETUP_TOKEN_WARN_DAYS:
387
+ return []
388
+ name = session["name"]
389
+ when = "has expired" if days < 0 else f"expires in {days} day(s)"
390
+ return [_issue("WARN", "claude_setup_token_expiry",
391
+ f"session {name} Claude setup-token {when}; run cdx login {name}",
392
+ {"session": name, "days_left": days})]
393
+
394
+
377
395
  def _codex_shared_account_id_issues(codex_diagnostics):
378
396
  by_account_id = {}
379
397
  for session, diag in codex_diagnostics:
package/src/notify.py CHANGED
@@ -23,6 +23,9 @@ def parse_ready_args(args):
23
23
  blocking wait loop — reachable through a `cdx notify` that no longer exists.
24
24
  `cdx ready` always schedules the next-ready event, so none of it could run.
25
25
  """
26
+ if len(args) == 2 and args[0] == "--fire":
27
+ # Internal: what a scheduled job runs when the reset time arrives.
28
+ return {"name": None, "mode": "fire", "fire": args[1], "json": False, "refresh": False, "schedule": False}
26
29
  unknown = [arg for arg in args if arg not in ("--refresh", "--json")]
27
30
  if unknown:
28
31
  raise CdxError(READY_USAGE)
@@ -243,7 +246,7 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
243
246
  "target_timestamp": target_timestamp,
244
247
  }
245
248
 
246
- argv = _scheduled_notify_argv(parsed, env)
249
+ argv = _scheduled_notify_argv(event, env)
247
250
  if sys.platform == "darwin":
248
251
  result = _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env)
249
252
  elif sys.platform == "win32":
@@ -255,17 +258,11 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
255
258
  return result
256
259
 
257
260
 
258
- def _scheduled_notify_argv(parsed, env):
261
+ def _scheduled_notify_argv(event, env):
259
262
  executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
260
- argv = [executable, "notify"]
261
- if parsed["mode"] == "at-reset":
262
- argv.append(parsed["name"])
263
- argv.append("--at-reset")
264
- else:
265
- argv.append("--next-ready")
266
- argv.append("--once")
267
- argv.append("--refresh")
268
- return argv
263
+ # `cdx notify` is the agent-hook target now and stays silent without a hook
264
+ # payload, so the job names the notification it has to show.
265
+ return [executable, "ready", "--fire", event.get("session") or "cdx"]
269
266
 
270
267
 
271
268
  def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
@@ -273,7 +270,7 @@ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
273
270
  schedule_dir = os.path.join(base_dir, "state", "notifications")
274
271
  os.makedirs(schedule_dir, mode=0o700, exist_ok=True)
275
272
  script_path = os.path.join(schedule_dir, f"{label}.sh")
276
- launch_agents = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents")
273
+ launch_agents = os.path.join(_operator_home(), "Library", "LaunchAgents")
277
274
  os.makedirs(launch_agents, exist_ok=True)
278
275
  plist_path = os.path.join(launch_agents, f"{label}.plist")
279
276
  target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
@@ -298,6 +295,14 @@ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
298
295
  return {"scheduled": True, "existing": False, "backend": "launchd", "id": label, "path": plist_path}
299
296
 
300
297
 
298
+ def _operator_home():
299
+ """The operator's real home. Inside a cdx session HOME is the profile home,
300
+ whose LaunchAgents folder launchd never reads."""
301
+ import pwd
302
+
303
+ return pwd.getpwuid(os.getuid()).pw_dir
304
+
305
+
301
306
  def _macos_schedule_script(argv, env, label, plist_path, script_path):
302
307
  lines = ["#!/bin/sh", f"export PATH={shlex.quote(env.get('PATH', '/usr/local/bin:/usr/bin:/bin'))}"]
303
308
  if env.get("CDX_HOME"):
@@ -12,7 +12,7 @@ import uuid
12
12
  from datetime import datetime, timezone
13
13
 
14
14
  from .agent_notify import launch_notify_env, notifications_enabled
15
- from .claude_credentials import peek_keychain_credentials
15
+ from .claude_credentials import peek_keychain_credentials, read_claude_login_file
16
16
  from .claude_usage import _clean_oauth_token, _decode_jwt_claims, _secure_storage_overrides, claude_profile_store_dir
17
17
  from .codex_usage import codex_auth_lock, diagnostic_needs_codex_login, fetch_codex_rate_limit_diagnostic
18
18
  from .config import (
@@ -26,7 +26,6 @@ from .config import (
26
26
  )
27
27
  from .errors import CdxError
28
28
 
29
- LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
30
29
  TERMINAL_TITLE_SEPARATOR = " — "
31
30
  # Providers redraw their own title while their TUI runs, so a single write at
32
31
  # launch does not survive. Re-asserting on this interval takes the title back
@@ -250,12 +249,8 @@ def _read_anthropic_oauth_token(auth_home):
250
249
 
251
250
 
252
251
  def _has_local_claude_auth(auth_home):
253
- try:
254
- with open(os.path.join(auth_home, ".claude", ".credentials.json"), encoding="utf-8") as handle:
255
- credentials = json.load(handle)
256
- except (FileNotFoundError, OSError, json.JSONDecodeError):
257
- return False
258
- oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
252
+ credentials = read_claude_login_file(auth_home)
253
+ oauth = credentials.get("claudeAiOauth") if credentials else None
259
254
  return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
260
255
 
261
256
 
@@ -539,14 +534,6 @@ def _list_launch_transcript_paths(session, glob_fn=None):
539
534
  return sorted(paths)
540
535
 
541
536
 
542
- def _rotate_log_if_needed(log_path):
543
- try:
544
- if os.path.getsize(log_path) >= LOG_ROTATE_BYTES:
545
- open(log_path, "w").close()
546
- except OSError:
547
- pass
548
-
549
-
550
537
  def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=None):
551
538
  if not capture_transcript:
552
539
  return spec
@@ -555,7 +542,6 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
555
542
  script_args = env.get("CDX_SCRIPT_ARGS")
556
543
  transcript_path = _build_launch_transcript_path(session)
557
544
  os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
558
- _rotate_log_if_needed(transcript_path)
559
545
  if script_args:
560
546
  args = shlex.split(script_args)
561
547
  if "{transcript}" in args:
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
8
8
  from .errors import CdxError
9
9
  from .fs_utils import atomic_write
10
10
  from .run_usage import coerce_usage
11
+ from .session_helpers import _process_is_running
11
12
 
12
13
 
13
14
  def utc_now_iso():
@@ -113,13 +114,7 @@ def _write_registry(path, data):
113
114
 
114
115
 
115
116
  def _is_pid_alive(pid):
116
- if not pid:
117
- return False
118
- try:
119
- os.kill(int(pid), 0)
120
- return True
121
- except (OSError, ValueError):
122
- return False
117
+ return _process_is_running(pid)
123
118
 
124
119
 
125
120
  def _completed_after(run, since):
@@ -164,6 +159,22 @@ def _refresh_stale_runs(data):
164
159
  return changed
165
160
 
166
161
 
162
+ # Every update rewrites the whole registry under the lock, so it must not grow
163
+ # with every run ever made. Running runs are always kept; runs are newest first.
164
+ MAX_FINISHED_RUNS = 500
165
+
166
+
167
+ def _prune_finished_runs(data):
168
+ kept, finished = [], 0
169
+ for run in data["runs"]:
170
+ if run.get("status") != "running":
171
+ finished += 1
172
+ if finished > MAX_FINISHED_RUNS:
173
+ continue
174
+ kept.append(run)
175
+ data["runs"] = kept
176
+
177
+
167
178
  def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None):
168
179
  return {
169
180
  "run_id": run_id,
@@ -218,6 +229,7 @@ class RunRegistry:
218
229
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
219
230
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
220
231
  data["runs"].insert(0, record)
232
+ _prune_finished_runs(data)
221
233
  _write_registry(self.path, data)
222
234
  return record
223
235
 
package/src/run_usage.py CHANGED
@@ -107,9 +107,17 @@ CACHE_WRITE_MULTIPLIER = 1.25
107
107
  DEFAULT_OUTPUT_MULTIPLIER = 5.0
108
108
 
109
109
 
110
+ def price_entry(model, prices=None):
111
+ """This model's rates. Claude Code records a context variant such as
112
+ `claude-opus-5-5[1m]`, which is billed at the base model's rates."""
113
+ table = prices if prices is not None else token_prices()[0]
114
+ model = model or ""
115
+ return table.get(model) or table.get(model.split("[", 1)[0])
116
+
117
+
110
118
  def output_multiplier(model, prices=None):
111
119
  """This model's output-to-input price ratio."""
112
- entry = (prices if prices is not None else token_prices()[0]).get(model or "")
120
+ entry = price_entry(model, prices)
113
121
  if not entry:
114
122
  return DEFAULT_OUTPUT_MULTIPLIER
115
123
  return entry["output"] / entry["input"] if entry["input"] else None
@@ -117,7 +125,7 @@ def output_multiplier(model, prices=None):
117
125
 
118
126
  def cache_read_multiplier(model, prices=None):
119
127
  """This model's cache-read/input ratio, with the legacy fallback."""
120
- entry = (prices if prices is not None else token_prices()[0]).get(model or "")
128
+ entry = price_entry(model, prices)
121
129
  if not entry:
122
130
  return CACHE_READ_MULTIPLIER
123
131
  if not entry["input"]:
@@ -185,7 +193,7 @@ DEFAULT_TOKEN_PRICES = {
185
193
  "claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
186
194
  "claude-mythos-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
187
195
  "claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_read": 0.2},
188
- "claude-sonnet-5-5": {"input": 2.0, "output": 10.0},
196
+ "claude-sonnet-5-5": {"input": 2.0, "output": 10.0, "cache_read": 0.1},
189
197
  "claude-fable-5": {"input": 10.0, "output": 50.0},
190
198
  "claude-mythos-5": {"input": 10.0, "output": 50.0},
191
199
  "claude-opus-5": {"input": 5.0, "output": 25.0},
@@ -197,6 +205,9 @@ DEFAULT_TOKEN_PRICES = {
197
205
  "claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
198
206
  "claude-sonnet-4-5": {"input": 3.0, "output": 15.0},
199
207
  "claude-sonnet-4-5-20250929": {"input": 3.0, "output": 15.0},
208
+ # ponytail: Haiku 5.5 costs 5x for prompts over 100K tokens; a run's usage
209
+ # does not say how its prompts were sized, so this is the base tier.
210
+ "claude-haiku-5-5": {"input": 0.1, "output": 0.5, "cache_read": 0.01},
200
211
  "claude-haiku-4-5": {"input": 1.0, "output": 5.0},
201
212
  "claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
202
213
  # OpenAI
@@ -218,7 +229,7 @@ DEFAULT_TOKEN_PRICES = {
218
229
  "gpt-5.4-nano-2026-03-17": {"input": 0.2, "output": 1.25},
219
230
  "gpt-5.3-codex": {"input": 1.75, "output": 14.0},
220
231
  }
221
- TOKEN_PRICES_REVIEWED = "2026-10-04"
232
+ TOKEN_PRICES_REVIEWED = "2026-10-22"
222
233
  #: How long a review stays fresh before the staleness test asks for another.
223
234
  TOKEN_PRICES_MAX_AGE_DAYS = 90
224
235
  TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
@@ -271,7 +282,7 @@ def estimate_cost(usage, model, prices=None):
271
282
  """
272
283
  if not model or not isinstance(usage, dict):
273
284
  return None
274
- rate = (prices if prices is not None else token_prices()[0]).get(model)
285
+ rate = price_entry(model, prices)
275
286
  if rate is None:
276
287
  return None
277
288
  per_million = {
@@ -98,6 +98,10 @@ def _process_is_running(pid):
98
98
  return False
99
99
  if pid <= 0:
100
100
  return False
101
+ if sys.platform == "win32":
102
+ # os.kill(pid, 0) is not a probe on Windows: signal 0 is CTRL_C_EVENT,
103
+ # so it would send Ctrl+C to the process group instead of asking.
104
+ return _windows_process_is_running(pid)
101
105
  try:
102
106
  os.kill(pid, 0)
103
107
  except ProcessLookupError:
@@ -108,6 +112,20 @@ def _process_is_running(pid):
108
112
  return False
109
113
  return True
110
114
 
115
+
116
+ def _windows_process_is_running(pid):
117
+ import ctypes
118
+
119
+ kernel32 = ctypes.windll.kernel32
120
+ handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
121
+ if not handle:
122
+ return kernel32.GetLastError() == 5 # ERROR_ACCESS_DENIED: alive, someone else's
123
+ try:
124
+ code = ctypes.c_ulong()
125
+ return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
126
+ finally:
127
+ kernel32.CloseHandle(handle)
128
+
111
129
  def _normalize_provider(provider):
112
130
  value = provider or DEFAULT_PROVIDER
113
131
  if value not in ALLOWED_PROVIDERS:
@@ -145,6 +145,24 @@ def _seed_codex_auth_from_global(auth_home, env=None):
145
145
  return True
146
146
 
147
147
 
148
+ _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview"}
149
+
150
+
151
+ def _parse_on_off(value, label):
152
+ if isinstance(value, bool):
153
+ return value
154
+ text = str(value).strip().lower()
155
+ if text in ("on", "true", "1", "yes"):
156
+ return True
157
+ if text in ("off", "false", "0", "no"):
158
+ return False
159
+ raise CdxError(f"Unsupported {label} value: {value}")
160
+
161
+
162
+ def _has_control_chars(text):
163
+ return any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
164
+
165
+
148
166
  def _normalize_launch_settings(settings, mark_fast_service_tier=True):
149
167
  normalized = {}
150
168
  if not settings:
@@ -172,77 +190,16 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
172
190
  f"Unsupported permission: {settings['permission']} (allowed values: {allowed})"
173
191
  )
174
192
  normalized["permission"] = permission
175
- if "fast" in settings and settings["fast"] is not None:
176
- value = settings["fast"]
177
- if isinstance(value, bool):
178
- normalized["fast"] = value
179
- else:
180
- text = str(value).strip().lower()
181
- if text in ("on", "true", "1", "yes"):
182
- normalized["fast"] = True
183
- elif text in ("off", "false", "0", "no"):
184
- normalized["fast"] = False
185
- else:
186
- raise CdxError(f"Unsupported fast value: {settings['fast']}")
187
- if normalized["fast"] is True and mark_fast_service_tier:
188
- normalized["fastMode"] = "service_tier"
189
- else:
190
- normalized.pop("fastMode", None)
191
- if settings.get("fastMode") == "service_tier" and normalized["fast"] is True:
192
- normalized["fastMode"] = "service_tier"
193
- if "rtk" in settings and settings["rtk"] is not None:
194
- value = settings["rtk"]
195
- if isinstance(value, bool):
196
- normalized["rtk"] = value
197
- else:
198
- text = str(value).strip().lower()
199
- if text in ("on", "true", "1", "yes"):
200
- normalized["rtk"] = True
201
- elif text in ("off", "false", "0", "no"):
202
- normalized["rtk"] = False
203
- else:
204
- raise CdxError(f"Unsupported rtk value: {settings['rtk']}")
205
- if "logics" in settings and settings["logics"] is not None:
206
- value = settings["logics"]
207
- if isinstance(value, bool):
208
- normalized["logics"] = value
209
- else:
210
- text = str(value).strip().lower()
211
- if text in ("on", "true", "1", "yes"):
212
- normalized["logics"] = True
213
- elif text in ("off", "false", "0", "no"):
214
- normalized["logics"] = False
215
- else:
216
- raise CdxError(f"Unsupported logics value: {settings['logics']}")
217
- if "notify" in settings and settings["notify"] is not None:
218
- value = settings["notify"]
219
- if isinstance(value, bool):
220
- normalized["notify"] = value
221
- else:
222
- text = str(value).strip().lower()
223
- if text in ("on", "true", "1", "yes"):
224
- normalized["notify"] = True
225
- elif text in ("off", "false", "0", "no"):
226
- normalized["notify"] = False
227
- else:
228
- raise CdxError(f"Unsupported notify value: {settings['notify']}")
229
- if "notify_preview" in settings and settings["notify_preview"] is not None:
230
- value = settings["notify_preview"]
231
- if isinstance(value, bool):
232
- normalized["notify_preview"] = value
233
- else:
234
- text = str(value).strip().lower()
235
- if text in ("on", "true", "1", "yes"):
236
- normalized["notify_preview"] = True
237
- elif text in ("off", "false", "0", "no"):
238
- normalized["notify_preview"] = False
239
- else:
240
- raise CdxError(f"Unsupported notify preview value: {settings['notify_preview']}")
193
+ for key, label in _ON_OFF_SETTINGS.items():
194
+ if key in settings and settings[key] is not None:
195
+ normalized[key] = _parse_on_off(settings[key], label)
196
+ if normalized.get("fast") is True and (mark_fast_service_tier or settings.get("fastMode") == "service_tier"):
197
+ normalized["fastMode"] = "service_tier"
241
198
  if "model" in settings and settings["model"] is not None:
242
199
  model = str(settings["model"]).strip()
243
200
  if not model:
244
201
  raise CdxError("Model cannot be empty.")
245
- if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
202
+ if len(model) > MAX_LAUNCH_MODEL_LENGTH or _has_control_chars(model):
246
203
  raise CdxError("Model contains unsupported characters.")
247
204
  normalized["model"] = model
248
205
  if "fallback_model" in settings and settings["fallback_model"] is not None:
@@ -255,14 +212,14 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
255
212
  if any(not element for element in elements):
256
213
  raise CdxError("Fallback model cannot contain an empty entry.")
257
214
  for element in elements:
258
- if len(element) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in element):
215
+ if len(element) > MAX_LAUNCH_MODEL_LENGTH or _has_control_chars(element):
259
216
  raise CdxError("Fallback model contains unsupported characters.")
260
217
  normalized["fallback_model"] = ",".join(elements)
261
218
  if "extra_args" in settings and settings["extra_args"] is not None:
262
219
  extra = str(settings["extra_args"]).strip()
263
220
  if not extra:
264
221
  raise CdxError("Extra args cannot be empty.")
265
- if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in extra):
222
+ if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or _has_control_chars(extra):
266
223
  raise CdxError("Extra args contain unsupported characters.")
267
224
  # Parsed here, not at launch: a string that cannot be split into argv
268
225
  # should be rejected when it is set, not when a run fails days later.
@@ -329,7 +286,7 @@ def _normalize_session_label(label):
329
286
  raise CdxError("Session label is required")
330
287
  if len(text) > MAX_SESSION_LABEL_LENGTH:
331
288
  raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
332
- if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
289
+ if _has_control_chars(text):
333
290
  raise CdxError("Session label cannot contain control characters")
334
291
  return text
335
292
 
@@ -9,6 +9,7 @@ import os
9
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
10
10
  from datetime import datetime, timezone
11
11
 
12
+ from .claude_credentials import claude_auth_type, setup_token_days_left
12
13
  from .claude_usage import _decode_jwt_claims
13
14
  from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, get_cdx_home
14
15
  from .errors import CdxError
@@ -269,6 +270,7 @@ def _status_row_from_session(store, s):
269
270
  enabled = s.get("enabled", True) is not False
270
271
  row_status = status if enabled else None
271
272
  runtime = _session_runtime(store, s["name"])
273
+ auth_type = claude_auth_type(s["authHome"]) if s["provider"] == PROVIDER_CLAUDE and s.get("authHome") else None
272
274
  return {
273
275
  "session_name": s["name"],
274
276
  "label": s.get("label"),
@@ -284,6 +286,8 @@ def _status_row_from_session(store, s):
284
286
  "cwd": runtime.get("cwd") if runtime else None,
285
287
  "status": "enabled" if enabled else "disabled",
286
288
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
289
+ "auth_type": auth_type,
290
+ "auth_expires_in_days": setup_token_days_left(s["authHome"]) if auth_type == "setup-token" else None,
287
291
  "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
288
292
  "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
289
293
  "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
@@ -1,12 +1,12 @@
1
1
  import json
2
2
  import os
3
3
  import sys
4
- import tempfile
5
4
  from contextlib import contextmanager
6
5
  from pathlib import Path
7
6
 
8
7
  from .errors import CdxError
9
8
  from .fs_utils import atomic_write
9
+ from .run_registry import REGISTRY_LOCK_TIMEOUT_SECONDS, _acquire_posix_lock, _acquire_windows_lock
10
10
 
11
11
 
12
12
  def _ensure_dir(path):
@@ -34,23 +34,8 @@ def _read_json(file_path, fallback):
34
34
 
35
35
 
36
36
  def _write_json(file_path, value):
37
- directory = os.path.dirname(file_path)
38
- _ensure_dir(directory)
39
- fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(file_path)}.", suffix=".tmp", dir=directory)
40
- try:
41
- with os.fdopen(fd, "w", encoding="utf-8") as f:
42
- json.dump(value, f, indent=2)
43
- f.write("\n")
44
- f.flush()
45
- os.fsync(f.fileno())
46
- os.replace(temp_path, file_path)
47
- _fsync_directory(directory)
48
- except Exception:
49
- try:
50
- os.unlink(temp_path)
51
- except FileNotFoundError:
52
- pass
53
- raise
37
+ _ensure_dir(os.path.dirname(file_path))
38
+ atomic_write(file_path, json.dumps(value, indent=2) + "\n")
54
39
 
55
40
 
56
41
  def _fsync_directory(directory):
@@ -71,11 +56,8 @@ def _file_lock(lock_path):
71
56
  with open(lock_path, "a", encoding="utf-8") as lock:
72
57
  if sys.platform == "win32":
73
58
  import msvcrt
74
- try:
75
- lock.seek(0)
76
- msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
77
- except OSError as error:
78
- raise CdxError(f"Failed to lock session store: {error}") from error
59
+ # Bounded wait, not LK_LOCK's ten one-second tries (see run_registry).
60
+ _acquire_windows_lock(lock, REGISTRY_LOCK_TIMEOUT_SECONDS, "session store")
79
61
  try:
80
62
  yield
81
63
  finally:
@@ -89,10 +71,7 @@ def _file_lock(lock_path):
89
71
  import fcntl
90
72
  except ImportError as error:
91
73
  raise CdxError("Session store locking requires fcntl on this platform") from error
92
- try:
93
- fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
94
- except OSError as error:
95
- raise CdxError(f"Failed to lock session store: {error}") from error
74
+ _acquire_posix_lock(lock, REGISTRY_LOCK_TIMEOUT_SECONDS, "session store")
96
75
  try:
97
76
  yield
98
77
  finally:
@@ -74,6 +74,17 @@ def _format_credits(value, empty="n/a"):
74
74
  return str(value)
75
75
 
76
76
 
77
+ def _auth_type_label(row):
78
+ """`setup-token`, or with its remaining days once renewal is due."""
79
+ from .claude_credentials import SETUP_TOKEN_WARN_DAYS
80
+
81
+ label = row.get("auth_type") or "-"
82
+ days = row.get("auth_expires_in_days")
83
+ if days is None or days > SETUP_TOKEN_WARN_DAYS:
84
+ return label, False
85
+ return (f"{label} expired" if days < 0 else f"{label} {days}d"), True
86
+
87
+
77
88
  def _format_status_rows(rows, use_color=False, small=False):
78
89
  active_rows = [r for r in rows if r.get("enabled", True) is not False]
79
90
  def positive(value):
@@ -90,18 +101,19 @@ def _format_status_rows(rows, use_color=False, small=False):
90
101
  credits_visible = any(r.get("credits") is not None for r in active_rows)
91
102
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
92
103
  has_label = any(r.get("label") for r in rows) and not small
104
+ type_visible = any(r.get("auth_type") for r in active_rows) and not small
93
105
  if small:
94
106
  headers = ["SESSION", "STATUS", "OK", "5H", "WEEK"] + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible]
95
107
  elif has_provider:
96
108
  headers = ["SESSION", "PROV."]
97
109
  if has_label:
98
110
  headers.append("LABEL")
99
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
111
+ headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
100
112
  else:
101
113
  headers = ["SESSION"]
102
114
  if has_label:
103
115
  headers.append("LABEL")
104
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
116
+ headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
105
117
  if not rows:
106
118
  if small:
107
119
  return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
@@ -130,6 +142,9 @@ def _format_status_rows(rows, use_color=False, small=False):
130
142
  auth = _format_auth_status(r)
131
143
  auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
132
144
  base.append(_style(auth, auth_color, use_color))
145
+ if type_visible:
146
+ label, warn = _auth_type_label(r)
147
+ base.append(_style(label, "33" if warn else "2", use_color))
133
148
  if r.get("enabled", True) is False:
134
149
  usage_columns = [_style("-", "2", use_color)] * 6
135
150
  else:
@@ -365,6 +380,7 @@ def _format_status_detail(row, use_color=False):
365
380
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
366
381
  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)}",
367
382
  f"{_style('Auth:', '1', use_color)} {_style(auth, auth_color, use_color)}",
383
+ *([f"{_style('Auth type:', '1', use_color)} {_auth_type_label(row)[0]}"] if row.get("auth_type") else []),
368
384
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
369
385
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
370
386
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
@@ -6,9 +6,10 @@ per-user temporary directory, then `cdx-tray/companion.pid`.
6
6
  """
7
7
  import os
8
8
  import platform
9
- import subprocess
10
9
  import tempfile
11
10
 
11
+ from .session_helpers import _process_is_running
12
+
12
13
 
13
14
  def lock_path(env=None):
14
15
  env = os.environ if env is None else env
@@ -20,20 +21,8 @@ def lock_path(env=None):
20
21
 
21
22
 
22
23
  def _is_alive(pid):
23
- if platform.system() == "Windows":
24
- result = subprocess.run(
25
- ["tasklist", "/fi", f"PID eq {pid}", "/nh"],
26
- capture_output=True, text=True, check=False,
27
- )
28
- return str(pid) in result.stdout
29
- try:
30
- os.kill(pid, 0)
31
- except (ProcessLookupError, ValueError):
32
- return False
33
- except PermissionError:
34
- # Alive, owned by someone else. Still a reason not to start a second.
35
- return True
36
- return True
24
+ # Alive includes owned by someone else: still a reason not to start a second.
25
+ return _process_is_running(pid)
37
26
 
38
27
 
39
28
  def companion_instance(env=None):