cdx-manager 0.9.16 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -10
- package/bin/cdx +2 -11
- package/changelogs/CHANGELOGS_0_10_0.md +43 -0
- package/changelogs/CHANGELOGS_0_11_0.md +51 -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 +163 -28
- package/src/cli_args.py +15 -12
- package/src/cli_commands.py +531 -9
- package/src/cli_helpers.py +33 -38
- package/src/cli_view.py +5 -27
- package/src/codex_usage.py +95 -24
- package/src/notify.py +6 -10
- package/src/provider_runtime.py +58 -14
- package/src/session_service.py +141 -57
- package/src/session_store.py +2 -2
- package/src/status_source.py +47 -25
- package/src/status_view.py +14 -6
package/src/cli_helpers.py
CHANGED
|
@@ -57,17 +57,16 @@ 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 []:
|
|
63
|
+
if notice.get("code") == "disk_cleanup_available":
|
|
64
|
+
warnings.append({
|
|
65
|
+
"code": "disk_cleanup_available",
|
|
66
|
+
"message": notice["message"],
|
|
67
|
+
**{key: value for key, value in notice.items() if key not in ("code", "message")},
|
|
68
|
+
})
|
|
69
|
+
continue
|
|
71
70
|
tool = notice.get("tool") or "cdx-manager"
|
|
72
71
|
current = notice.get("current_version") or ctx.get("version")
|
|
73
72
|
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|
|
@@ -111,7 +110,7 @@ def _format_bytes(value):
|
|
|
111
110
|
amount /= 1024
|
|
112
111
|
if unit == "B":
|
|
113
112
|
return f"{int(amount)} B"
|
|
114
|
-
return f"{amount:.1f} {unit}"
|
|
113
|
+
return f"{int(amount)} {unit}" if amount.is_integer() else f"{amount:.1f} {unit}"
|
|
115
114
|
|
|
116
115
|
|
|
117
116
|
def _format_export_report(result):
|
|
@@ -150,39 +149,16 @@ def _make_export_progress(ctx):
|
|
|
150
149
|
return progress
|
|
151
150
|
|
|
152
151
|
|
|
153
|
-
def
|
|
154
|
-
progress_state = {"checked": 0, "total": 0}
|
|
155
|
-
|
|
156
|
-
def progress(event):
|
|
157
|
-
kind = event.get("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
|
-
message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
|
|
162
|
-
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
163
|
-
elif kind == "session_started":
|
|
164
|
-
provider = event.get("provider") or "session"
|
|
165
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
166
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
167
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
168
|
-
progress_state["checked"] += 1
|
|
169
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
170
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
171
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
172
|
-
elif kind == "status_finished":
|
|
173
|
-
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
174
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
175
|
-
return progress
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _make_notify_progress(ctx):
|
|
152
|
+
def _make_session_progress(ctx, start_event, start_message):
|
|
179
153
|
progress_state = {"checked": 0, "total": 0}
|
|
180
154
|
|
|
181
155
|
def progress(event):
|
|
182
156
|
kind = event.get("event")
|
|
183
|
-
if kind ==
|
|
184
|
-
|
|
185
|
-
|
|
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")
|
|
186
162
|
elif kind == "status_started":
|
|
187
163
|
progress_state["checked"] = 0
|
|
188
164
|
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
@@ -197,12 +173,31 @@ def _make_notify_progress(ctx):
|
|
|
197
173
|
total = progress_state["total"] or progress_state["checked"]
|
|
198
174
|
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
199
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")
|
|
200
179
|
elif kind == "notify_waiting":
|
|
201
180
|
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
202
181
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
203
182
|
return progress
|
|
204
183
|
|
|
205
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
|
+
|
|
206
201
|
def _latest_launch_transcript_path(session):
|
|
207
202
|
paths = _list_launch_transcript_paths(session)
|
|
208
203
|
if not paths:
|
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:
|
|
@@ -65,6 +71,15 @@ def _format_reset_date(unix_seconds):
|
|
|
65
71
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
66
72
|
|
|
67
73
|
|
|
74
|
+
def _format_timestamp(unix_seconds):
|
|
75
|
+
if unix_seconds is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone().isoformat()
|
|
79
|
+
except (TypeError, ValueError, OSError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
68
83
|
def _remaining_from_used_percent(value):
|
|
69
84
|
if value is None:
|
|
70
85
|
return None
|
|
@@ -84,7 +99,7 @@ def _get_window(snapshot, duration_mins):
|
|
|
84
99
|
return {}
|
|
85
100
|
|
|
86
101
|
|
|
87
|
-
def normalize_codex_rate_limit_snapshot(snapshot):
|
|
102
|
+
def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
|
|
88
103
|
if not snapshot:
|
|
89
104
|
return None
|
|
90
105
|
|
|
@@ -101,6 +116,23 @@ def normalize_codex_rate_limit_snapshot(snapshot):
|
|
|
101
116
|
|
|
102
117
|
reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
|
|
103
118
|
reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
|
|
119
|
+
reset_credits = reset_credits or {}
|
|
120
|
+
available_count = reset_credits.get("availableCount", reset_credits.get("available_count"))
|
|
121
|
+
try:
|
|
122
|
+
available_count = max(0, int(available_count)) if available_count is not None else None
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
available_count = None
|
|
125
|
+
credit_rows = []
|
|
126
|
+
for credit in reset_credits.get("credits") or []:
|
|
127
|
+
if not isinstance(credit, dict) or credit.get("status") not in (None, "available"):
|
|
128
|
+
continue
|
|
129
|
+
credit_rows.append({
|
|
130
|
+
"id": credit.get("id"),
|
|
131
|
+
"title": credit.get("title"),
|
|
132
|
+
"description": credit.get("description"),
|
|
133
|
+
"granted_at": _format_timestamp(credit.get("grantedAt", credit.get("granted_at"))),
|
|
134
|
+
"expires_at": _format_timestamp(credit.get("expiresAt", credit.get("expires_at"))),
|
|
135
|
+
})
|
|
104
136
|
|
|
105
137
|
raw_status_text = json.dumps(snapshot, sort_keys=True)
|
|
106
138
|
return {
|
|
@@ -111,6 +143,8 @@ def normalize_codex_rate_limit_snapshot(snapshot):
|
|
|
111
143
|
weekly.get("usedPercent", weekly.get("used_percent"))
|
|
112
144
|
),
|
|
113
145
|
"credits": credit_balance,
|
|
146
|
+
"reset_credits_available": available_count,
|
|
147
|
+
"reset_credits": credit_rows,
|
|
114
148
|
"reset_5h_at": reset_5h_at,
|
|
115
149
|
"reset_week_at": reset_week_at,
|
|
116
150
|
"reset_at": reset_week_at or reset_5h_at,
|
|
@@ -166,6 +200,32 @@ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
|
|
|
166
200
|
|
|
167
201
|
|
|
168
202
|
def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_factory=None):
|
|
203
|
+
diagnostic = _request_codex_app_server(
|
|
204
|
+
auth_home,
|
|
205
|
+
"account/rateLimits/read",
|
|
206
|
+
None,
|
|
207
|
+
"rate_limits_read_failed",
|
|
208
|
+
timeout=timeout,
|
|
209
|
+
popen_factory=popen_factory,
|
|
210
|
+
)
|
|
211
|
+
if not diagnostic["ok"]:
|
|
212
|
+
return diagnostic
|
|
213
|
+
response = diagnostic["response"]
|
|
214
|
+
result = response.get("result") or {}
|
|
215
|
+
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
216
|
+
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
217
|
+
status = normalize_codex_rate_limit_snapshot(snapshot, result.get("rateLimitResetCredits"))
|
|
218
|
+
if not status:
|
|
219
|
+
return {
|
|
220
|
+
"ok": False,
|
|
221
|
+
"reason": "missing_rate_limits",
|
|
222
|
+
"status": None,
|
|
223
|
+
"response": response,
|
|
224
|
+
}
|
|
225
|
+
return {"ok": True, "reason": None, "status": status, "response": response}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _request_codex_app_server(auth_home, method, params, failure_reason, timeout=5, popen_factory=None):
|
|
169
229
|
env = os.environ.copy()
|
|
170
230
|
env["CODEX_HOME"] = auth_home
|
|
171
231
|
popen_factory = popen_factory or subprocess.Popen
|
|
@@ -204,29 +264,18 @@ def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_fact
|
|
|
204
264
|
_write_json_line(process, {
|
|
205
265
|
"jsonrpc": "2.0",
|
|
206
266
|
"id": 2,
|
|
207
|
-
"method":
|
|
208
|
-
"params":
|
|
267
|
+
"method": method,
|
|
268
|
+
"params": params,
|
|
209
269
|
})
|
|
210
270
|
response = _read_response(output, 2, timeout)
|
|
211
271
|
if not response or response.get("error"):
|
|
212
272
|
return {
|
|
213
273
|
"ok": False,
|
|
214
|
-
"reason":
|
|
215
|
-
"status": None,
|
|
216
|
-
"response": response,
|
|
217
|
-
}
|
|
218
|
-
result = response.get("result") or {}
|
|
219
|
-
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
220
|
-
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
221
|
-
status = normalize_codex_rate_limit_snapshot(snapshot)
|
|
222
|
-
if not status:
|
|
223
|
-
return {
|
|
224
|
-
"ok": False,
|
|
225
|
-
"reason": "missing_rate_limits",
|
|
274
|
+
"reason": failure_reason,
|
|
226
275
|
"status": None,
|
|
227
276
|
"response": response,
|
|
228
277
|
}
|
|
229
|
-
return {"ok": True, "reason": None, "status":
|
|
278
|
+
return {"ok": True, "reason": None, "status": None, "response": response}
|
|
230
279
|
except FileNotFoundError as error:
|
|
231
280
|
return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
|
|
232
281
|
except (OSError, ValueError, BrokenPipeError) as error:
|
|
@@ -250,3 +299,25 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
|
250
299
|
popen_factory=popen_factory,
|
|
251
300
|
)
|
|
252
301
|
return diagnostic.get("status") if diagnostic.get("ok") else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def consume_codex_rate_limit_reset_credit(session, idempotency_key, credit_id=None, timeout=5, popen_factory=None):
|
|
305
|
+
auth_home = session.get("authHome")
|
|
306
|
+
if not auth_home:
|
|
307
|
+
return {"ok": False, "reason": "missing_auth_home", "outcome": None}
|
|
308
|
+
with codex_auth_lock(auth_home) as acquired:
|
|
309
|
+
if not acquired:
|
|
310
|
+
return {"ok": False, "reason": "auth_locked", "outcome": None}
|
|
311
|
+
params = {"idempotencyKey": idempotency_key}
|
|
312
|
+
if credit_id:
|
|
313
|
+
params["creditId"] = credit_id
|
|
314
|
+
diagnostic = _request_codex_app_server(
|
|
315
|
+
auth_home,
|
|
316
|
+
"account/rateLimitResetCredit/consume",
|
|
317
|
+
params,
|
|
318
|
+
"reset_consume_failed",
|
|
319
|
+
timeout=timeout,
|
|
320
|
+
popen_factory=popen_factory,
|
|
321
|
+
)
|
|
322
|
+
result = (diagnostic.get("response") or {}).get("result") or {}
|
|
323
|
+
return {**diagnostic, "outcome": result.get("outcome")}
|
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']}"
|