cdx-manager 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/bin/cdx +2 -11
- package/changelogs/CHANGELOGS_0_11_0.md +51 -0
- package/changelogs/CHANGELOGS_0_11_1.md +34 -0
- package/checksums/release-archives.json +8 -0
- package/install.ps1 +12 -19
- package/install.sh +6 -29
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +17 -33
- package/src/cli.py +9 -36
- package/src/cli_args.py +30 -12
- package/src/cli_commands.py +46 -17
- package/src/cli_helpers.py +26 -38
- package/src/cli_render.py +5 -0
- package/src/cli_view.py +5 -27
- package/src/codex_usage.py +14 -8
- package/src/notify.py +6 -10
- package/src/provider_runtime.py +58 -14
- package/src/session_service.py +174 -57
- package/src/session_store.py +2 -2
- package/src/status_source.py +47 -25
- package/src/status_view.py +11 -2
package/src/cli_commands.py
CHANGED
|
@@ -37,6 +37,7 @@ from .cli_args import (
|
|
|
37
37
|
_parse_history_args,
|
|
38
38
|
_parse_import_args,
|
|
39
39
|
_parse_json_flag,
|
|
40
|
+
_parse_label_args,
|
|
40
41
|
_parse_launch_setting_alias_args,
|
|
41
42
|
_parse_next_args,
|
|
42
43
|
_parse_remove_args,
|
|
@@ -56,6 +57,7 @@ from .cli_args import (
|
|
|
56
57
|
from .cli_helpers import (
|
|
57
58
|
API_SCHEMA_VERSION,
|
|
58
59
|
_build_handoff_context,
|
|
60
|
+
_format_bytes,
|
|
59
61
|
_format_export_report,
|
|
60
62
|
_handoff_launch_prompt,
|
|
61
63
|
_json_failure,
|
|
@@ -96,10 +98,12 @@ from .notify import (
|
|
|
96
98
|
wait_for_notification_event,
|
|
97
99
|
)
|
|
98
100
|
from .provider_runtime import (
|
|
101
|
+
AUTH_PROBE_AUTHENTICATED,
|
|
102
|
+
AUTH_PROBE_DEGRADED,
|
|
99
103
|
_ensure_session_authentication,
|
|
100
104
|
_headless_artifact_paths,
|
|
101
105
|
_list_launch_transcript_paths,
|
|
102
|
-
|
|
106
|
+
_probe_provider_auth_status,
|
|
103
107
|
_run_headless_provider_command,
|
|
104
108
|
_run_interactive_provider_command,
|
|
105
109
|
get_resume_capability,
|
|
@@ -171,14 +175,6 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
171
175
|
return confirmed
|
|
172
176
|
|
|
173
177
|
|
|
174
|
-
def _format_bytes(value):
|
|
175
|
-
size = float(value)
|
|
176
|
-
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
177
|
-
if size < 1024 or unit == "TB":
|
|
178
|
-
return f"{int(size)} {unit}" if unit == "B" or size.is_integer() else f"{size:.1f} {unit}"
|
|
179
|
-
size /= 1024
|
|
180
|
-
|
|
181
|
-
|
|
182
178
|
def _directory_size_bytes(path, runner=None):
|
|
183
179
|
if not os.path.exists(path):
|
|
184
180
|
raise CdxError(f"CDX home does not exist: {path}")
|
|
@@ -468,6 +464,13 @@ def _handle_clean_profiles(args, ctx, json_flag):
|
|
|
468
464
|
raise CdxError(usage)
|
|
469
465
|
old_logs = _parse_days(args[index + 1], usage)
|
|
470
466
|
args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
|
|
467
|
+
else:
|
|
468
|
+
old_log_equals = [arg for arg in args if arg.startswith("--old-logs=")]
|
|
469
|
+
if old_log_equals:
|
|
470
|
+
if len(old_log_equals) > 1:
|
|
471
|
+
raise CdxError(usage)
|
|
472
|
+
old_logs = _parse_days(old_log_equals[0].split("=", 1)[1], usage)
|
|
473
|
+
args = [arg for arg in args if arg != old_log_equals[0]]
|
|
471
474
|
args = [arg for arg in args if arg != "--tmp"]
|
|
472
475
|
if args or clean_tmp == (old_logs is not None):
|
|
473
476
|
raise CdxError(usage)
|
|
@@ -527,6 +530,8 @@ def handle_disk(rest, ctx):
|
|
|
527
530
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
528
531
|
}, DISK_USAGE, positionals_key="targets", max_positionals=1)
|
|
529
532
|
target = parsed["targets"][0] if parsed["targets"] else "home"
|
|
533
|
+
if parsed["candidates"] and target != "profiles":
|
|
534
|
+
raise CdxError(DISK_USAGE)
|
|
530
535
|
if target == "home":
|
|
531
536
|
path = ctx["service"]["base_dir"]
|
|
532
537
|
elif target == "profiles":
|
|
@@ -558,8 +563,6 @@ def handle_disk(rest, ctx):
|
|
|
558
563
|
if target == "profiles":
|
|
559
564
|
payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
|
|
560
565
|
if parsed["candidates"]:
|
|
561
|
-
if target != "profiles":
|
|
562
|
-
raise CdxError(DISK_USAGE)
|
|
563
566
|
candidates = _collect_profile_cleanup_candidates(
|
|
564
567
|
ctx["service"]["base_dir"],
|
|
565
568
|
runner=ctx["options"].get("diskUsageRunner"),
|
|
@@ -730,6 +733,21 @@ def handle_rename(rest, ctx):
|
|
|
730
733
|
return 0
|
|
731
734
|
|
|
732
735
|
|
|
736
|
+
def handle_label(rest, ctx):
|
|
737
|
+
parsed = _parse_label_args(rest)
|
|
738
|
+
if parsed["clear"]:
|
|
739
|
+
session = ctx["service"]["clear_session_label"](parsed["name"])
|
|
740
|
+
message = f"Cleared label for {parsed['name']}"
|
|
741
|
+
else:
|
|
742
|
+
session = ctx["service"]["set_session_label"](parsed["name"], parsed["label"])
|
|
743
|
+
message = f"Set label for {parsed['name']}: {session['label']}"
|
|
744
|
+
if parsed["json"]:
|
|
745
|
+
_write_json(ctx, _json_success("label", message, session=session, label=session.get("label")))
|
|
746
|
+
return 0
|
|
747
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
748
|
+
return 0
|
|
749
|
+
|
|
750
|
+
|
|
733
751
|
def handle_remove(rest, ctx):
|
|
734
752
|
json_flag, args = _parse_json_flag(rest)
|
|
735
753
|
parsed = _parse_remove_args(args)
|
|
@@ -1735,8 +1753,12 @@ def handle_last(rest, ctx):
|
|
|
1735
1753
|
|
|
1736
1754
|
def handle_clean(rest, ctx):
|
|
1737
1755
|
json_flag, args = _parse_json_flag(rest)
|
|
1738
|
-
|
|
1739
|
-
|
|
1756
|
+
profile_flags = ("--tmp", "--old-logs")
|
|
1757
|
+
has_profile_flag = any(arg in profile_flags or arg.startswith("--old-logs=") for arg in args)
|
|
1758
|
+
if args[:1] == ["profiles"] or has_profile_flag:
|
|
1759
|
+
if args[:1] == ["profiles"]:
|
|
1760
|
+
return _handle_clean_profiles(args[1:], ctx, json_flag)
|
|
1761
|
+
return _handle_clean_profiles(args, ctx, json_flag)
|
|
1740
1762
|
yes = "--yes" in args
|
|
1741
1763
|
args = [arg for arg in args if arg != "--yes"]
|
|
1742
1764
|
service = ctx["service"]
|
|
@@ -2190,17 +2212,23 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
|
|
|
2190
2212
|
if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
|
|
2191
2213
|
continue
|
|
2192
2214
|
try:
|
|
2193
|
-
|
|
2215
|
+
auth_status = _probe_provider_auth_status(
|
|
2194
2216
|
session,
|
|
2195
2217
|
spawn_sync=spawn_sync,
|
|
2196
2218
|
env_override=env_override,
|
|
2197
2219
|
)
|
|
2220
|
+
if auth_status == AUTH_PROBE_DEGRADED:
|
|
2221
|
+
errors.append({
|
|
2222
|
+
"session": session["name"],
|
|
2223
|
+
"error": "Auth probe timed out; authentication status is degraded.",
|
|
2224
|
+
})
|
|
2225
|
+
continue
|
|
2198
2226
|
now = _local_now_iso()
|
|
2199
|
-
service["update_auth_state"](session["name"], lambda auth,
|
|
2227
|
+
service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
|
|
2200
2228
|
**auth,
|
|
2201
|
-
"status": "authenticated" if
|
|
2229
|
+
"status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
|
|
2202
2230
|
"lastCheckedAt": now,
|
|
2203
|
-
**({"lastAuthenticatedAt": now} if
|
|
2231
|
+
**({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
|
|
2204
2232
|
})
|
|
2205
2233
|
updated.append(session["name"])
|
|
2206
2234
|
except Exception as error:
|
|
@@ -2326,6 +2354,7 @@ def handle_import(rest, ctx):
|
|
|
2326
2354
|
session_names=parsed["session_names"],
|
|
2327
2355
|
force=parsed["force"],
|
|
2328
2356
|
merge=parsed["merge"],
|
|
2357
|
+
allow_authless_force=parsed["allow_authless_force"],
|
|
2329
2358
|
)
|
|
2330
2359
|
session_count = len(result["session_names"])
|
|
2331
2360
|
auth_suffix = " with auth" if result["include_auth"] else ""
|
package/src/cli_helpers.py
CHANGED
|
@@ -57,14 +57,6 @@ def _write_json(ctx, payload):
|
|
|
57
57
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def _update_notice_warning(ctx):
|
|
61
|
-
notices = ctx.get("update_notices") or []
|
|
62
|
-
if not notices:
|
|
63
|
-
return None
|
|
64
|
-
warnings = _update_notice_warnings(ctx)
|
|
65
|
-
return warnings[0] if warnings else None
|
|
66
|
-
|
|
67
|
-
|
|
68
60
|
def _update_notice_warnings(ctx):
|
|
69
61
|
warnings = []
|
|
70
62
|
for notice in ctx.get("update_notices") or []:
|
|
@@ -118,7 +110,7 @@ def _format_bytes(value):
|
|
|
118
110
|
amount /= 1024
|
|
119
111
|
if unit == "B":
|
|
120
112
|
return f"{int(amount)} B"
|
|
121
|
-
return f"{amount:.1f} {unit}"
|
|
113
|
+
return f"{int(amount)} {unit}" if amount.is_integer() else f"{amount:.1f} {unit}"
|
|
122
114
|
|
|
123
115
|
|
|
124
116
|
def _format_export_report(result):
|
|
@@ -157,39 +149,16 @@ def _make_export_progress(ctx):
|
|
|
157
149
|
return progress
|
|
158
150
|
|
|
159
151
|
|
|
160
|
-
def
|
|
152
|
+
def _make_session_progress(ctx, start_event, start_message):
|
|
161
153
|
progress_state = {"checked": 0, "total": 0}
|
|
162
154
|
|
|
163
155
|
def progress(event):
|
|
164
156
|
kind = event.get("event")
|
|
165
|
-
if kind ==
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
ctx["out"](f"{_info(
|
|
170
|
-
elif kind == "session_started":
|
|
171
|
-
provider = event.get("provider") or "session"
|
|
172
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
173
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
174
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
175
|
-
progress_state["checked"] += 1
|
|
176
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
177
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
178
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
179
|
-
elif kind == "status_finished":
|
|
180
|
-
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
181
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
182
|
-
return progress
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _make_notify_progress(ctx):
|
|
186
|
-
progress_state = {"checked": 0, "total": 0}
|
|
187
|
-
|
|
188
|
-
def progress(event):
|
|
189
|
-
kind = event.get("event")
|
|
190
|
-
if kind == "notify_check_started":
|
|
191
|
-
target = event.get("session_name") or "next ready session"
|
|
192
|
-
ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
|
|
157
|
+
if kind == start_event:
|
|
158
|
+
if kind == "status_started":
|
|
159
|
+
progress_state["checked"] = 0
|
|
160
|
+
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
161
|
+
ctx["out"](f"{_info(start_message(event), ctx['use_color'])}\n")
|
|
193
162
|
elif kind == "status_started":
|
|
194
163
|
progress_state["checked"] = 0
|
|
195
164
|
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
@@ -204,12 +173,31 @@ def _make_notify_progress(ctx):
|
|
|
204
173
|
total = progress_state["total"] or progress_state["checked"]
|
|
205
174
|
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
206
175
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
176
|
+
elif kind == "status_finished":
|
|
177
|
+
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
178
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
207
179
|
elif kind == "notify_waiting":
|
|
208
180
|
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
209
181
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
210
182
|
return progress
|
|
211
183
|
|
|
212
184
|
|
|
185
|
+
def _make_status_progress(ctx):
|
|
186
|
+
return _make_session_progress(
|
|
187
|
+
ctx,
|
|
188
|
+
"status_started",
|
|
189
|
+
lambda event: f"Resolving status for {event.get('session_count', 0)} session(s)...",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _make_notify_progress(ctx):
|
|
194
|
+
return _make_session_progress(
|
|
195
|
+
ctx,
|
|
196
|
+
"notify_check_started",
|
|
197
|
+
lambda event: f"Checking notification target: {event.get('session_name') or 'next ready session'}...",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
213
201
|
def _latest_launch_transcript_path(session):
|
|
214
202
|
paths = _list_launch_transcript_paths(session)
|
|
215
203
|
if not paths:
|
package/src/cli_render.py
CHANGED
|
@@ -123,10 +123,13 @@ def format_error(error, env=None, stderr=None):
|
|
|
123
123
|
def _format_sessions(service, use_color=False):
|
|
124
124
|
rows = service["format_list_rows"]()
|
|
125
125
|
has_provider = any(r.get("provider") for r in rows)
|
|
126
|
+
has_label = any(r.get("label") for r in rows)
|
|
126
127
|
has_launch = any(r.get("launch") for r in rows)
|
|
127
128
|
headers = ["SESSION"]
|
|
128
129
|
if has_provider:
|
|
129
130
|
headers.append("PROVIDER")
|
|
131
|
+
if has_label:
|
|
132
|
+
headers.append("LABEL")
|
|
130
133
|
headers.append("STATUS")
|
|
131
134
|
if has_launch:
|
|
132
135
|
headers.append("LAUNCH")
|
|
@@ -138,6 +141,8 @@ def _format_sessions(service, use_color=False):
|
|
|
138
141
|
parts = [name]
|
|
139
142
|
if has_provider:
|
|
140
143
|
parts.append(r.get("provider") or "n/a")
|
|
144
|
+
if has_label:
|
|
145
|
+
parts.append(r.get("label") or "-")
|
|
141
146
|
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
142
147
|
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
143
148
|
if has_launch:
|
package/src/cli_view.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .cli_helpers import _json_success, _write_json
|
|
1
|
+
from .cli_helpers import _json_failure, _json_success, _update_notice_warnings, _write_json
|
|
2
2
|
from .cli_render import _warn
|
|
3
3
|
from .errors import CdxError
|
|
4
4
|
from .logics_view import (
|
|
@@ -13,31 +13,6 @@ from .update_check import check_logics_manager_for_update
|
|
|
13
13
|
VIEW_USAGE = "Usage: cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--open] [--no-open]"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def _update_notice_warnings(notices):
|
|
17
|
-
warnings = []
|
|
18
|
-
for notice in notices or []:
|
|
19
|
-
if not notice:
|
|
20
|
-
continue
|
|
21
|
-
tool = notice.get("tool") or "logics-manager"
|
|
22
|
-
current = notice.get("current_version")
|
|
23
|
-
command = notice.get("update_command")
|
|
24
|
-
message = f"Update available: {tool} {notice['latest_version']}"
|
|
25
|
-
if current:
|
|
26
|
-
message = f"{message} (current {current})"
|
|
27
|
-
if command:
|
|
28
|
-
message = f"{message}. Run: {command}"
|
|
29
|
-
warnings.append({
|
|
30
|
-
"code": f"{tool.replace('-', '_')}_update_available",
|
|
31
|
-
"message": message,
|
|
32
|
-
"tool": tool,
|
|
33
|
-
"latest_version": notice["latest_version"],
|
|
34
|
-
"current_version": current,
|
|
35
|
-
"update_command": command,
|
|
36
|
-
"url": notice.get("url"),
|
|
37
|
-
})
|
|
38
|
-
return warnings
|
|
39
|
-
|
|
40
|
-
|
|
41
16
|
def _logics_manager_update_notice(ctx, env):
|
|
42
17
|
checker = ctx["options"].get("checkLogicsManagerForUpdate") or check_logics_manager_for_update
|
|
43
18
|
return checker(
|
|
@@ -86,9 +61,12 @@ def handle_view(rest, ctx):
|
|
|
86
61
|
update_notice = _logics_manager_update_notice(ctx, env) if executable else None
|
|
87
62
|
failure = None if executable else missing_logics_manager_failure()
|
|
88
63
|
diagnostics = build_viewer_diagnostics(executable, cwd, update_notice=update_notice, failure=failure, extra_args=viewer_args)
|
|
89
|
-
warnings = _update_notice_warnings([update_notice])
|
|
64
|
+
warnings = _update_notice_warnings({"update_notices": [update_notice] if update_notice else []})
|
|
90
65
|
|
|
91
66
|
if json_flag:
|
|
67
|
+
if failure:
|
|
68
|
+
_write_json(ctx, _json_failure("view", "viewer_unavailable", failure["message"], warnings=warnings, viewer=diagnostics))
|
|
69
|
+
return 1
|
|
92
70
|
_write_json(ctx, _json_success("view", "Collected Logics viewer diagnostics", warnings=warnings, viewer=diagnostics))
|
|
93
71
|
return 0
|
|
94
72
|
|
package/src/codex_usage.py
CHANGED
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import queue
|
|
5
5
|
import subprocess
|
|
6
6
|
import threading
|
|
7
|
+
import time
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
|
|
9
10
|
from .status_source import _is_zero_credit_balance
|
|
@@ -17,7 +18,7 @@ CODEX_AUTH_LOCK_NAME = ".cdx-auth.lock"
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@contextlib.contextmanager
|
|
20
|
-
def codex_auth_lock(auth_home, blocking=False):
|
|
21
|
+
def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_interval_seconds=0.05):
|
|
21
22
|
"""Serialize codex token refreshes per CODEX_HOME.
|
|
22
23
|
|
|
23
24
|
Codex rotates its OAuth refresh_token on refresh and invalidates the old
|
|
@@ -36,13 +37,18 @@ def codex_auth_lock(auth_home, blocking=False):
|
|
|
36
37
|
except OSError:
|
|
37
38
|
yield True
|
|
38
39
|
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
fcntl.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
|
41
|
+
while True:
|
|
42
|
+
flags = fcntl.LOCK_EX if blocking and timeout_seconds is None else fcntl.LOCK_EX | fcntl.LOCK_NB
|
|
43
|
+
try:
|
|
44
|
+
fcntl.flock(handle, flags)
|
|
45
|
+
break
|
|
46
|
+
except OSError:
|
|
47
|
+
if not blocking or (deadline is not None and time.monotonic() >= deadline):
|
|
48
|
+
handle.close()
|
|
49
|
+
yield False
|
|
50
|
+
return
|
|
51
|
+
time.sleep(retry_interval_seconds)
|
|
46
52
|
try:
|
|
47
53
|
yield True
|
|
48
54
|
finally:
|
package/src/notify.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import shlex
|
|
4
|
+
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
import time
|
|
6
7
|
from datetime import datetime, timedelta
|
|
@@ -165,14 +166,14 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
|
165
166
|
env = env or os.environ
|
|
166
167
|
if sys.platform == "win32":
|
|
167
168
|
_send_windows_notification(title, message, spawn_sync, env)
|
|
168
|
-
elif
|
|
169
|
+
elif shutil.which("osascript", path=env.get("PATH")):
|
|
169
170
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
170
171
|
_run_notification_command(
|
|
171
172
|
["osascript", "-e", script],
|
|
172
173
|
spawn_sync,
|
|
173
174
|
env,
|
|
174
175
|
)
|
|
175
|
-
elif
|
|
176
|
+
elif shutil.which("notify-send", path=env.get("PATH")):
|
|
176
177
|
_run_notification_command(
|
|
177
178
|
["notify-send", str(title), str(message)],
|
|
178
179
|
spawn_sync,
|
|
@@ -209,11 +210,6 @@ def _escape_powershell(value):
|
|
|
209
210
|
return str(value).replace("'", "''")
|
|
210
211
|
|
|
211
212
|
|
|
212
|
-
def shutil_which(command, env):
|
|
213
|
-
import shutil
|
|
214
|
-
return shutil.which(command, path=env.get("PATH"))
|
|
215
|
-
|
|
216
|
-
|
|
217
213
|
def _escape_applescript(value):
|
|
218
214
|
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
219
215
|
|
|
@@ -247,7 +243,7 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
|
|
|
247
243
|
|
|
248
244
|
|
|
249
245
|
def _scheduled_notify_argv(parsed, env):
|
|
250
|
-
executable = env.get("CDX_BIN") or
|
|
246
|
+
executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
|
|
251
247
|
argv = [executable, "notify"]
|
|
252
248
|
if parsed["mode"] == "at-reset":
|
|
253
249
|
argv.append(parsed["name"])
|
|
@@ -331,7 +327,7 @@ def _launchd_plist(label, script_path, target):
|
|
|
331
327
|
def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
332
328
|
unit = _schedule_id("cdx-manager-notify", parsed, event)
|
|
333
329
|
target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
|
|
334
|
-
if
|
|
330
|
+
if shutil.which("systemd-run", path=env.get("PATH")):
|
|
335
331
|
calendar = target.strftime("%Y-%m-%d %H:%M:%S")
|
|
336
332
|
result = _run_scheduler_command(
|
|
337
333
|
["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
|
|
@@ -343,7 +339,7 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
|
343
339
|
if _scheduler_error_means_exists(result["error"]):
|
|
344
340
|
return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
|
|
345
341
|
raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
|
|
346
|
-
if
|
|
342
|
+
if shutil.which("at", path=env.get("PATH")):
|
|
347
343
|
at_time = target.strftime("%Y%m%d%H%M.%S")
|
|
348
344
|
command = " ".join(shlex.quote(str(part)) for part in argv)
|
|
349
345
|
result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
|
package/src/provider_runtime.py
CHANGED
|
@@ -16,6 +16,11 @@ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVI
|
|
|
16
16
|
from .errors import CdxError
|
|
17
17
|
|
|
18
18
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
19
|
+
AUTH_PROBE_TIMEOUT_SECONDS = 15
|
|
20
|
+
CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
|
|
21
|
+
AUTH_PROBE_AUTHENTICATED = "authenticated"
|
|
22
|
+
AUTH_PROBE_LOGGED_OUT = "logged_out"
|
|
23
|
+
AUTH_PROBE_DEGRADED = "degraded"
|
|
19
24
|
REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
|
|
20
25
|
RTK_PROMPT = (
|
|
21
26
|
"When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
|
|
@@ -173,12 +178,15 @@ def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
|
|
|
173
178
|
"live_error": None,
|
|
174
179
|
}
|
|
175
180
|
try:
|
|
176
|
-
|
|
181
|
+
status = _probe_provider_auth_status(
|
|
177
182
|
session,
|
|
178
183
|
spawn_sync=spawn_sync,
|
|
179
184
|
env_override=env_override,
|
|
180
185
|
trust_local_credentials=False,
|
|
181
|
-
)
|
|
186
|
+
)
|
|
187
|
+
result["live_status"] = status
|
|
188
|
+
if status == AUTH_PROBE_DEGRADED:
|
|
189
|
+
result["live_error"] = _auth_probe_degraded_message(session)
|
|
182
190
|
except CdxError as error:
|
|
183
191
|
result["live_status"] = "error"
|
|
184
192
|
result["live_error"] = str(error)
|
|
@@ -899,14 +907,21 @@ def _resolve_command(command, env=None):
|
|
|
899
907
|
return shutil.which(command, path=env.get("PATH")) or command
|
|
900
908
|
|
|
901
909
|
|
|
902
|
-
def
|
|
910
|
+
def _auth_probe_degraded_message(session):
|
|
911
|
+
return (
|
|
912
|
+
f"Auth probe timed out after {AUTH_PROBE_TIMEOUT_SECONDS}s for session {session.get('name') or 'unknown'}; "
|
|
913
|
+
"authentication status is degraded."
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
|
|
903
918
|
spawn_sync = spawn_sync or subprocess.run
|
|
904
919
|
spec = _build_login_status_spec(session, env_override)
|
|
905
920
|
if trust_local_credentials:
|
|
906
921
|
if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
|
|
907
|
-
return
|
|
922
|
+
return AUTH_PROBE_AUTHENTICATED
|
|
908
923
|
if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
|
|
909
|
-
return
|
|
924
|
+
return AUTH_PROBE_AUTHENTICATED
|
|
910
925
|
try:
|
|
911
926
|
if spawn_sync is subprocess.run:
|
|
912
927
|
command = _resolve_command(spec["command"], spec["env"])
|
|
@@ -914,19 +929,33 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
|
|
|
914
929
|
[command] + spec["args"],
|
|
915
930
|
env=spec["env"],
|
|
916
931
|
capture_output=True, text=True,
|
|
932
|
+
timeout=AUTH_PROBE_TIMEOUT_SECONDS,
|
|
917
933
|
)
|
|
918
934
|
output = (result.stdout or "") + (result.stderr or "")
|
|
919
935
|
else:
|
|
920
936
|
result = spawn_sync(spec["command"], spec["args"], spec)
|
|
921
937
|
error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
|
|
922
938
|
if error:
|
|
939
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
940
|
+
raise error
|
|
923
941
|
raise _format_probe_failure(session, spec, error)
|
|
924
942
|
stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
|
|
925
943
|
stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
|
|
926
944
|
output = (stdout or "") + (stderr or "")
|
|
945
|
+
except subprocess.TimeoutExpired:
|
|
946
|
+
return AUTH_PROBE_DEGRADED
|
|
927
947
|
except OSError as error:
|
|
928
948
|
raise _format_probe_failure(session, spec, error) from error
|
|
929
|
-
return spec["parser"](output)
|
|
949
|
+
return AUTH_PROBE_AUTHENTICATED if spec["parser"](output) else AUTH_PROBE_LOGGED_OUT
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
|
|
953
|
+
return _probe_provider_auth_status(
|
|
954
|
+
session,
|
|
955
|
+
spawn_sync=spawn_sync,
|
|
956
|
+
env_override=env_override,
|
|
957
|
+
trust_local_credentials=trust_local_credentials,
|
|
958
|
+
) == AUTH_PROBE_AUTHENTICATED
|
|
930
959
|
|
|
931
960
|
|
|
932
961
|
def _signal_exit_code(sig):
|
|
@@ -953,10 +982,15 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
953
982
|
lifecycle_callback=lifecycle_callback)
|
|
954
983
|
if session.get("provider") != PROVIDER_CODEX:
|
|
955
984
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
956
|
-
# Hold the per-CODEX_HOME lock for the whole session so
|
|
957
|
-
#
|
|
958
|
-
|
|
959
|
-
|
|
985
|
+
# Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
|
|
986
|
+
# processes cannot rotate the refresh_token under each other.
|
|
987
|
+
with codex_auth_lock(
|
|
988
|
+
_get_auth_home(session),
|
|
989
|
+
blocking=True,
|
|
990
|
+
timeout_seconds=CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS,
|
|
991
|
+
) as acquired:
|
|
992
|
+
if not acquired:
|
|
993
|
+
raise CdxError(f"Timed out waiting for Codex auth lock for session {session['name']}.")
|
|
960
994
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
961
995
|
|
|
962
996
|
|
|
@@ -1114,16 +1148,26 @@ def _ensure_session_authentication(session, service, spawn=None, spawn_sync=None
|
|
|
1114
1148
|
raise CdxError(
|
|
1115
1149
|
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|
|
1116
1150
|
)
|
|
1117
|
-
|
|
1151
|
+
auth_status = _probe_provider_auth_status(
|
|
1118
1152
|
session,
|
|
1119
1153
|
spawn_sync=spawn_sync,
|
|
1120
1154
|
env_override=env_override,
|
|
1121
1155
|
trust_local_credentials=trust_local_credentials,
|
|
1122
1156
|
)
|
|
1123
|
-
if
|
|
1124
|
-
return {"authenticated": True, "checked": True}
|
|
1157
|
+
if auth_status == AUTH_PROBE_AUTHENTICATED:
|
|
1158
|
+
return {"authenticated": True, "checked": True, "status": auth_status}
|
|
1159
|
+
if auth_status == AUTH_PROBE_DEGRADED:
|
|
1160
|
+
result = {
|
|
1161
|
+
"authenticated": False,
|
|
1162
|
+
"checked": True,
|
|
1163
|
+
"status": auth_status,
|
|
1164
|
+
"error": _auth_probe_degraded_message(session),
|
|
1165
|
+
}
|
|
1166
|
+
if behavior == "probe-only":
|
|
1167
|
+
return result
|
|
1168
|
+
raise CdxError(f"{result['error']} Retry, or run: cdx login {session['name']}")
|
|
1125
1169
|
if behavior == "probe-only":
|
|
1126
|
-
return {"authenticated": False, "checked": True}
|
|
1170
|
+
return {"authenticated": False, "checked": True, "status": auth_status}
|
|
1127
1171
|
if behavior == "launch":
|
|
1128
1172
|
raise CdxError(
|
|
1129
1173
|
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|