cdx-manager 0.21.3 → 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 +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_4.md +10 -0
- package/changelogs/CHANGELOGS_0_21_5.md +26 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +2 -1
- package/docs/operations.md +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +15 -5
- package/src/claude_credentials.py +85 -0
- package/src/cli.py +13 -9
- package/src/cli_helpers.py +9 -3
- package/src/commands/auth.py +6 -0
- package/src/commands/launch.py +32 -16
- package/src/commands/tray.py +0 -20
- package/src/handoff.py +24 -0
- package/src/health.py +18 -0
- package/src/notify.py +17 -12
- package/src/provider_runtime.py +30 -22
- package/src/run_registry.py +19 -7
- package/src/run_usage.py +16 -5
- package/src/session_helpers.py +18 -0
- package/src/session_service.py +27 -70
- package/src/session_status.py +4 -0
- package/src/session_store.py +6 -27
- package/src/status_view.py +18 -2
- package/src/tray_instance.py +4 -15
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
1
|
+
0.21.5
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# CDX Manager 0.21.4
|
|
2
|
+
|
|
3
|
+
## Start a session on a first message
|
|
4
|
+
|
|
5
|
+
- `cdx <name> --prompt TEXT` and `cdx <name> --prompt-file PATH` launch an interactive session that starts on that message, for launcher scripts that open a session already pointed at a task. Both spellings `--flag value` and `--flag=value` work. They are refused before the provider starts when combined with each other, with `--resume` or `cdx resume`, with `--prompt-file -` (the session needs standard input), and for Ollama sessions (`ollama run` answers once and exits). A relative `--prompt-file` path is read from the current directory, not `--dir`. Contributed by Nicolas Rigaudy (#13).
|
|
6
|
+
|
|
7
|
+
## Fixes
|
|
8
|
+
|
|
9
|
+
- Claude sessions started from inside a running Claude Code session (a hook, or a terminal or script it spawned) saved no transcript: they inherited the parent's session markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, ...) and ran as a child session, which broke resume and interactive usage backfill. CDX now drops those markers for every Claude spawn; user settings such as `CLAUDE_CODE_DISABLE_*` still pass through. Contributed by Nicolas Rigaudy (#14).
|
|
10
|
+
- A prompt starting with `-` (from `--prompt`, `cdx handoff` or `cdx run`) is now passed after `--`, so Claude, Codex and Ollama no longer read it as an option (for example `-h` printed the provider's help instead of starting).
|
|
@@ -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).
|
|
@@ -507,6 +507,28 @@
|
|
|
507
507
|
"x86_64-pc-windows-msvc": "856f9dc459182adb10f66f2836abc862be82feff1cc3f1cf859764336487d781",
|
|
508
508
|
"x86_64-unknown-linux-musl": "f1832fdbe7114b4625da21d6a3a0f0c100f18d3a441796e809e568620e4cd345"
|
|
509
509
|
}
|
|
510
|
+
},
|
|
511
|
+
"v0.21.3": {
|
|
512
|
+
"github_tarball_sha256": "4f38562539953131cd2d8aedffea71ba1e7b2d8ccbc899b6521a1ed8c7507cf2",
|
|
513
|
+
"github_zip_sha256": "06e95cf08fcdec0e978c16980f58f282b01daee0513ab8db1ea659b423e58dc6",
|
|
514
|
+
"tray_assets": {
|
|
515
|
+
"aarch64-apple-darwin": "086ec24d2d1ad2820394e74a5f100cec62da1ffe2df91ee470c247f5b1cb9bb0",
|
|
516
|
+
"aarch64-unknown-linux-musl": "0f32a168e097a4d18da86fe0ae028465d5fb43afae6344da39a76463ba99c0ea",
|
|
517
|
+
"x86_64-apple-darwin": "348ec8e5ed89c1181a800ab65b5d736a46b8118d7b83445847049cae95735af8",
|
|
518
|
+
"x86_64-pc-windows-msvc": "9729f245b46420ae4b11c1a50d3ba9cba682854866ef37a3f334dccbdd70ac89",
|
|
519
|
+
"x86_64-unknown-linux-musl": "182fb651dab29a6a21b3b8ef56c196e954fd75230891f8532c21b09ff5fbacee"
|
|
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
|
+
}
|
|
510
532
|
}
|
|
511
533
|
}
|
|
512
534
|
}
|
package/docs/cli.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
| `cdx --json` | List all sessions as a machine-readable JSON payload |
|
|
9
9
|
| `cdx <name>` | Launch a session (checks auth first) |
|
|
10
10
|
| `cdx <name> [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
|
|
11
|
+
| `cdx <name> --prompt TEXT` / `cdx <name> --prompt-file PATH` | Launch a session that starts on this first message (not with `--resume` or Ollama sessions; `--prompt-file -` is refused because the session needs standard input; a relative `--prompt-file` path is read from the current directory, not `--dir`) |
|
|
11
12
|
| `cdx <name> -r` / `cdx <name> --resume` | Resume the provider-native conversation for a session when supported |
|
|
12
13
|
| `cdx add [provider] <name> [--model MODEL] [--json]` | Register a new session (`provider`: `codex`, `claude`, `antigravity`, or `ollama`; Ollama requires `--model`) |
|
|
13
14
|
| `cdx cp <source> <dest> [--json]` | Copy a session into another session name, overwriting the destination if it exists |
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
| `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
|
|
56
57
|
| `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
|
|
57
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 |
|
|
58
|
-
| `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. |
|
|
59
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 |
|
|
60
61
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
61
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 |
|
package/docs/operations.md
CHANGED
|
@@ -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
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
@@ -70,11 +70,8 @@ from .cli_render import (
|
|
|
70
70
|
_visible_len,
|
|
71
71
|
format_error,
|
|
72
72
|
)
|
|
73
|
+
from .config import PROVIDER_OLLAMA
|
|
73
74
|
from .errors import CdxError
|
|
74
|
-
from .provider_runtime import (
|
|
75
|
-
LOG_ROTATE_BYTES,
|
|
76
|
-
_rotate_log_if_needed,
|
|
77
|
-
)
|
|
78
75
|
from .session_service import create_session_service
|
|
79
76
|
from .status_view import (
|
|
80
77
|
_format_blocking_quota,
|
|
@@ -137,13 +134,11 @@ VERSION = _resolve_version()
|
|
|
137
134
|
# them here documents that and stops F401 from treating them as dead imports.
|
|
138
135
|
__all__ = [
|
|
139
136
|
"STATUS_USAGE",
|
|
140
|
-
"LOG_ROTATE_BYTES",
|
|
141
137
|
"_format_blocking_quota",
|
|
142
138
|
"_format_reset_time",
|
|
143
139
|
"_format_status_detail",
|
|
144
140
|
"_format_status_rows",
|
|
145
141
|
"_pad_table",
|
|
146
|
-
"_rotate_log_if_needed",
|
|
147
142
|
"_visible_len",
|
|
148
143
|
"cli_entry",
|
|
149
144
|
"format_json_error",
|
|
@@ -288,6 +283,7 @@ def _print_help(use_color=False):
|
|
|
288
283
|
f" {_style('cdx set <name> --notify on', '36', use_color)} # agent finishes or needs attention",
|
|
289
284
|
f" {_style('cdx ready', '36', use_color)} # next session to become ready",
|
|
290
285
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
286
|
+
f" {_style('cdx <name> --prompt TEXT|--prompt-file PATH', '36', use_color)} # start with a first message",
|
|
291
287
|
f" {_style('cdx --help', '36', use_color)}",
|
|
292
288
|
f" {_style('cdx --version', '36', use_color)}",
|
|
293
289
|
])
|
|
@@ -623,10 +619,18 @@ def main(argv, options=None):
|
|
|
623
619
|
if handler:
|
|
624
620
|
return handler(rest, ctx)
|
|
625
621
|
|
|
626
|
-
from .commands.launch import _parse_launch_args
|
|
627
|
-
directory, launch_args = _parse_launch_args(rest)
|
|
622
|
+
from .commands.launch import _parse_launch_args, _read_launch_prompt
|
|
623
|
+
directory, launch_args, prompt = _parse_launch_args(rest)
|
|
628
624
|
if all(arg in ("--json", "-r", "--resume") for arg in launch_args):
|
|
629
|
-
|
|
625
|
+
resume = "-r" in launch_args or "--resume" in launch_args
|
|
626
|
+
has_prompt = prompt["prompt"] is not None or prompt["prompt_file"] is not None
|
|
627
|
+
if resume and has_prompt:
|
|
628
|
+
raise CdxError("cdx <name>: --prompt and --prompt-file are not supported with --resume.")
|
|
629
|
+
# `ollama run MODEL PROMPT` answers once and exits instead of opening a session.
|
|
630
|
+
if has_prompt and (ctx["service"]["get_session"](command) or {}).get("provider") == PROVIDER_OLLAMA:
|
|
631
|
+
raise CdxError("cdx <name>: --prompt and --prompt-file are not supported for Ollama sessions.")
|
|
632
|
+
return handle_launch(command, ctx, initial_prompt=_read_launch_prompt(prompt), resume=resume,
|
|
633
|
+
directory=directory)
|
|
630
634
|
|
|
631
635
|
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
632
636
|
|
package/src/cli_helpers.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
#
|
|
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):
|
package/src/commands/auth.py
CHANGED
|
@@ -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),
|
package/src/commands/launch.py
CHANGED
|
@@ -30,31 +30,45 @@ from ..provider_runtime import (
|
|
|
30
30
|
_get_auth_home,
|
|
31
31
|
_run_interactive_provider_command,
|
|
32
32
|
)
|
|
33
|
+
from ..run_command import PROMPT_STDIN, read_run_prompt
|
|
33
34
|
from ..run_usage import estimate_cost
|
|
34
35
|
|
|
36
|
+
LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH] [--resume] [--json]"
|
|
37
|
+
|
|
35
38
|
|
|
36
39
|
def _parse_launch_args(args):
|
|
37
|
-
"""Return the per-launch directory and ordinary launch flags."""
|
|
38
|
-
|
|
40
|
+
"""Return the per-launch directory, initial prompt source and ordinary launch flags."""
|
|
41
|
+
values = {"--dir": None, "--prompt": None, "--prompt-file": None}
|
|
39
42
|
cleaned = []
|
|
40
43
|
index = 0
|
|
41
44
|
while index < len(args):
|
|
42
45
|
arg = args[index]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
directory = args[index + 1]
|
|
47
|
-
index += 2
|
|
48
|
-
continue
|
|
49
|
-
if arg.startswith("--dir="):
|
|
50
|
-
if directory is not None or not arg.split("=", 1)[1]:
|
|
51
|
-
raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
|
|
52
|
-
directory = arg.split("=", 1)[1]
|
|
46
|
+
flag, sep, inline = arg.partition("=")
|
|
47
|
+
if flag not in values:
|
|
48
|
+
cleaned.append(arg)
|
|
53
49
|
index += 1
|
|
54
50
|
continue
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
if sep:
|
|
52
|
+
value, index = inline, index + 1
|
|
53
|
+
elif index + 1 < len(args):
|
|
54
|
+
value, index = args[index + 1], index + 2
|
|
55
|
+
else:
|
|
56
|
+
raise CdxError(LAUNCH_USAGE)
|
|
57
|
+
if values[flag] is not None or not value:
|
|
58
|
+
raise CdxError(LAUNCH_USAGE)
|
|
59
|
+
values[flag] = value
|
|
60
|
+
if values["--prompt"] is not None and values["--prompt-file"] is not None:
|
|
61
|
+
raise CdxError("cdx <name>: --prompt and --prompt-file are mutually exclusive.")
|
|
62
|
+
if values["--prompt-file"] == PROMPT_STDIN:
|
|
63
|
+
raise CdxError("cdx <name>: --prompt-file - is not supported; the interactive session needs standard input.")
|
|
64
|
+
prompt = {"prompt": values["--prompt"], "prompt_file": values["--prompt-file"]}
|
|
65
|
+
return values["--dir"], cleaned, prompt
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_launch_prompt(prompt):
|
|
69
|
+
if prompt["prompt"] is None and prompt["prompt_file"] is None:
|
|
70
|
+
return None
|
|
71
|
+
return read_run_prompt(prompt)
|
|
58
72
|
|
|
59
73
|
|
|
60
74
|
def _launch_directory(session, ctx, explicit, json_flag):
|
|
@@ -154,7 +168,9 @@ def handle_can_resume(rest, ctx):
|
|
|
154
168
|
return 0
|
|
155
169
|
|
|
156
170
|
def handle_resume(rest, ctx):
|
|
157
|
-
directory, rest = _parse_launch_args(rest)
|
|
171
|
+
directory, rest, prompt = _parse_launch_args(rest)
|
|
172
|
+
if prompt["prompt"] is not None or prompt["prompt_file"] is not None:
|
|
173
|
+
raise CdxError("cdx resume: --prompt and --prompt-file are not supported when resuming.")
|
|
158
174
|
json_flag, args = _parse_json_flag(rest)
|
|
159
175
|
if len(args) != 1:
|
|
160
176
|
raise CdxError(RESUME_USAGE)
|
package/src/commands/tray.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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(
|
|
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"):
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
|
@@ -137,6 +136,21 @@ REDACTED_PROMPT_ARG = "[prompt redacted]"
|
|
|
137
136
|
# Documented Claude Code switch that stops it from setting and clearing the
|
|
138
137
|
# terminal title, leaving cdx's shared title keeper as the only writer.
|
|
139
138
|
CLAUDE_DISABLE_TERMINAL_TITLE_ENV = "CLAUDE_CODE_DISABLE_TERMINAL_TITLE"
|
|
139
|
+
# Set by a running Claude Code session for its own child processes. When cdx is
|
|
140
|
+
# started from inside one (a hook, or a terminal or script it spawned), the new
|
|
141
|
+
# claude inherits them, treats itself as a child session and turns transcript
|
|
142
|
+
# saving off, which breaks resume and usage backfill.
|
|
143
|
+
CLAUDE_PARENT_SESSION_ENV = (
|
|
144
|
+
"CLAUDECODE",
|
|
145
|
+
"CLAUDE_CODE_CHILD_SESSION",
|
|
146
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
147
|
+
"CLAUDE_CODE_SESSION_ATTENDED",
|
|
148
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
149
|
+
"CLAUDE_CODE_EXECPATH",
|
|
150
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
151
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
152
|
+
"CLAUDE_PID",
|
|
153
|
+
)
|
|
140
154
|
CLAUDE_CLI_MODEL_ALIASES = {
|
|
141
155
|
"claude-sonnet": "sonnet",
|
|
142
156
|
"claude-opus": "opus",
|
|
@@ -216,6 +230,8 @@ def _claude_env(base_env, auth_home, own_terminal_title=False):
|
|
|
216
230
|
env = {**base_env, **_home_env_overrides(auth_home)}
|
|
217
231
|
env.pop("CLAUDE_CONFIG_DIR", None)
|
|
218
232
|
env.pop("CODEX_HOME", None)
|
|
233
|
+
for key in CLAUDE_PARENT_SESSION_ENV:
|
|
234
|
+
env.pop(key, None)
|
|
219
235
|
env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
|
|
220
236
|
if own_terminal_title:
|
|
221
237
|
env[CLAUDE_DISABLE_TERMINAL_TITLE_ENV] = "1"
|
|
@@ -233,12 +249,8 @@ def _read_anthropic_oauth_token(auth_home):
|
|
|
233
249
|
|
|
234
250
|
|
|
235
251
|
def _has_local_claude_auth(auth_home):
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
credentials = json.load(handle)
|
|
239
|
-
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
240
|
-
return False
|
|
241
|
-
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
|
|
242
254
|
return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
|
|
243
255
|
|
|
244
256
|
|
|
@@ -522,14 +534,6 @@ def _list_launch_transcript_paths(session, glob_fn=None):
|
|
|
522
534
|
return sorted(paths)
|
|
523
535
|
|
|
524
536
|
|
|
525
|
-
def _rotate_log_if_needed(log_path):
|
|
526
|
-
try:
|
|
527
|
-
if os.path.getsize(log_path) >= LOG_ROTATE_BYTES:
|
|
528
|
-
open(log_path, "w").close()
|
|
529
|
-
except OSError:
|
|
530
|
-
pass
|
|
531
|
-
|
|
532
|
-
|
|
533
537
|
def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=None):
|
|
534
538
|
if not capture_transcript:
|
|
535
539
|
return spec
|
|
@@ -538,7 +542,6 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
538
542
|
script_args = env.get("CDX_SCRIPT_ARGS")
|
|
539
543
|
transcript_path = _build_launch_transcript_path(session)
|
|
540
544
|
os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
|
|
541
|
-
_rotate_log_if_needed(transcript_path)
|
|
542
545
|
if script_args:
|
|
543
546
|
args = shlex.split(script_args)
|
|
544
547
|
if "{transcript}" in args:
|
|
@@ -672,7 +675,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
672
675
|
args += preference_args
|
|
673
676
|
args += _extra_args(session)
|
|
674
677
|
if initial_prompt:
|
|
675
|
-
|
|
678
|
+
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
679
|
+
args += ["--", initial_prompt]
|
|
676
680
|
auth_home = _get_auth_home(session)
|
|
677
681
|
claude_env = _claude_env(env, auth_home, own_terminal_title=True)
|
|
678
682
|
oauth_token = _read_claude_launch_oauth_token(auth_home)
|
|
@@ -711,7 +715,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
711
715
|
# failed only on the model pull.
|
|
712
716
|
args = ["run", model] + _launch_config_args(session)
|
|
713
717
|
if initial_prompt:
|
|
714
|
-
|
|
718
|
+
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
719
|
+
args += ["--", initial_prompt]
|
|
715
720
|
return _wrap_launch_with_transcript(session, {
|
|
716
721
|
"command": "ollama",
|
|
717
722
|
"args": args,
|
|
@@ -728,7 +733,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
728
733
|
args += _launch_config_args(session)
|
|
729
734
|
args += preference_args
|
|
730
735
|
if initial_prompt:
|
|
731
|
-
|
|
736
|
+
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
737
|
+
args += ["--", initial_prompt]
|
|
732
738
|
return _wrap_launch_with_transcript(session, {
|
|
733
739
|
"command": "codex",
|
|
734
740
|
"args": args,
|
|
@@ -933,7 +939,8 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
933
939
|
args += preference_args
|
|
934
940
|
args += _extra_args(session)
|
|
935
941
|
if initial_prompt:
|
|
936
|
-
|
|
942
|
+
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
943
|
+
args += ["--", initial_prompt]
|
|
937
944
|
auth_home = _get_auth_home(session)
|
|
938
945
|
claude_env = _claude_env(env, auth_home)
|
|
939
946
|
oauth_token = _read_claude_launch_oauth_token(auth_home)
|
|
@@ -958,7 +965,8 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
958
965
|
args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
|
|
959
966
|
args += preference_args
|
|
960
967
|
if initial_prompt:
|
|
961
|
-
|
|
968
|
+
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
969
|
+
args += ["--", initial_prompt]
|
|
962
970
|
return {
|
|
963
971
|
"command": "codex",
|
|
964
972
|
"args": args,
|
package/src/run_registry.py
CHANGED
|
@@ -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
|
-
|
|
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 = (
|
|
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 = (
|
|
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-
|
|
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 = (
|
|
285
|
+
rate = price_entry(model, prices)
|
|
275
286
|
if rate is None:
|
|
276
287
|
return None
|
|
277
288
|
per_million = {
|
package/src/session_helpers.py
CHANGED
|
@@ -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:
|
package/src/session_service.py
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
289
|
+
if _has_control_chars(text):
|
|
333
290
|
raise CdxError("Session label cannot contain control characters")
|
|
334
291
|
return text
|
|
335
292
|
|
package/src/session_status.py
CHANGED
|
@@ -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,
|
package/src/session_store.py
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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:
|
package/src/status_view.py
CHANGED
|
@@ -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"
|
|
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"
|
|
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)}",
|
package/src/tray_instance.py
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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):
|