cdx-manager 0.21.4 → 0.21.6
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_5.md +26 -0
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +3 -3
- package/docs/operations.md +2 -0
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +16 -6
- package/src/backup_bundle.py +60 -16
- package/src/claude_credentials.py +85 -0
- package/src/claude_usage.py +1 -1
- package/src/cli.py +6 -8
- package/src/cli_args.py +43 -15
- package/src/cli_helpers.py +12 -5
- package/src/codex_usage.py +1 -1
- package/src/commands/auth.py +6 -0
- package/src/commands/runs.py +3 -3
- package/src/commands/tray.py +0 -20
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +24 -0
- package/src/health.py +20 -2
- package/src/notify.py +40 -30
- package/src/provider_background.py +1 -1
- package/src/provider_runtime.py +55 -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 +34 -92
- package/src/session_status.py +4 -0
- package/src/session_store.py +6 -27
- package/src/status_view.py +18 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +4 -15
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- package/src/update_manager.py +7 -3
package/src/cli_args.py
CHANGED
|
@@ -16,6 +16,7 @@ from .config import (
|
|
|
16
16
|
PROVIDERS,
|
|
17
17
|
REASONING_EFFORT_VALUES,
|
|
18
18
|
normalize_permission,
|
|
19
|
+
parse_on_off,
|
|
19
20
|
)
|
|
20
21
|
from .errors import CdxArgumentError, CdxError
|
|
21
22
|
from .provider_runtime import _normalize_reasoning_effort
|
|
@@ -32,8 +33,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
|
|
|
32
33
|
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
33
34
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
|
|
34
35
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
35
|
-
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
|
|
36
|
-
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
|
36
|
+
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--notify-preview on|off] [--scrub-env on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
|
|
37
|
+
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--scrub-env|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
|
37
38
|
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
38
39
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
39
40
|
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
@@ -213,13 +214,12 @@ def _parse_toggle_args(args, usage):
|
|
|
213
214
|
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
214
215
|
|
|
215
216
|
|
|
216
|
-
def _parse_fast_value(value):
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
raise CdxError(SET_USAGE)
|
|
217
|
+
def _parse_fast_value(value, label="fast"):
|
|
218
|
+
return parse_on_off(value, label)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _on_off(label):
|
|
222
|
+
return lambda value: parse_on_off(value, label)
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
def _parse_priority_value(value):
|
|
@@ -248,10 +248,11 @@ def _parse_set_args(args):
|
|
|
248
248
|
"--power": {"key": "power", "type": "str", "default": None},
|
|
249
249
|
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
250
250
|
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
|
-
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform":
|
|
252
|
-
"--logics": {"key": "logics", "type": "str", "default": None, "transform":
|
|
253
|
-
"--notify": {"key": "notify", "type": "str", "default": None, "transform":
|
|
254
|
-
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform":
|
|
251
|
+
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _on_off("rtk")},
|
|
252
|
+
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
|
|
253
|
+
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
|
|
254
|
+
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
|
|
255
|
+
"--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
|
|
255
256
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
256
257
|
"--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
|
|
257
258
|
"--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
|
|
@@ -273,7 +274,7 @@ def _parse_set_args(args):
|
|
|
273
274
|
raise CdxError(SET_USAGE)
|
|
274
275
|
settings = {
|
|
275
276
|
key: parsed[key]
|
|
276
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
277
|
+
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
277
278
|
if parsed[key] is not None
|
|
278
279
|
}
|
|
279
280
|
if not settings:
|
|
@@ -297,6 +298,7 @@ def _parse_unset_args(args):
|
|
|
297
298
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
298
299
|
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
299
300
|
"--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
|
|
301
|
+
"--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
|
|
300
302
|
"--model": {"key": "model", "type": "bool", "default": False},
|
|
301
303
|
"--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
|
|
302
304
|
"--budget": {"key": "budget", "type": "bool", "default": False},
|
|
@@ -318,7 +320,7 @@ def _parse_unset_args(args):
|
|
|
318
320
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
319
321
|
raise CdxError(UNSET_USAGE)
|
|
320
322
|
_UNSET_KEYS = (
|
|
321
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
323
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env",
|
|
322
324
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
323
325
|
)
|
|
324
326
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
|
@@ -510,8 +512,14 @@ def cdx_schema():
|
|
|
510
512
|
"provider_start_failed",
|
|
511
513
|
"provider_failed",
|
|
512
514
|
"provider_timeout",
|
|
515
|
+
"run_cancelled",
|
|
516
|
+
"invalid_request",
|
|
513
517
|
"cdx_error",
|
|
514
518
|
],
|
|
519
|
+
# The error code on a run record the stale sweep closed.
|
|
520
|
+
"run_record": [
|
|
521
|
+
"stale_process",
|
|
522
|
+
],
|
|
515
523
|
"run_lookup": [
|
|
516
524
|
"run_not_found",
|
|
517
525
|
"run_output_unavailable",
|
|
@@ -522,6 +530,26 @@ def cdx_schema():
|
|
|
522
530
|
"network_disabled_by_permission",
|
|
523
531
|
"session_selected_without_status",
|
|
524
532
|
"limit_ignored_with_since",
|
|
533
|
+
"claude_auth_probe_failed",
|
|
534
|
+
"claude_refresh_failed",
|
|
535
|
+
"disk_cleanup_available",
|
|
536
|
+
"update_available",
|
|
537
|
+
"logics_manager_update_available",
|
|
538
|
+
"update_version_check_failed",
|
|
539
|
+
"update_version_mismatch",
|
|
540
|
+
"logics_manager_missing",
|
|
541
|
+
"logics_viewer_failed",
|
|
542
|
+
"logics_viewer_root_invalid",
|
|
543
|
+
"tray_autostart_unsupported",
|
|
544
|
+
"tray_companion_missing",
|
|
545
|
+
"tray_companion_not_aligned",
|
|
546
|
+
"tray_companion_not_available",
|
|
547
|
+
"tray_companion_not_installed",
|
|
548
|
+
"tray_companion_restart_pending",
|
|
549
|
+
"tray_companion_updated",
|
|
550
|
+
"tray_plugin_action_unknown",
|
|
551
|
+
"tray_plugin_unknown",
|
|
552
|
+
"tray_terminal_invalid",
|
|
525
553
|
],
|
|
526
554
|
}
|
|
527
555
|
|
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):
|
|
@@ -297,6 +303,7 @@ def _format_launch_config(session, use_color=False):
|
|
|
297
303
|
("logics", "Logics"),
|
|
298
304
|
("notify", "Agent alerts"),
|
|
299
305
|
("notify_preview", "Response preview"),
|
|
306
|
+
("scrub_env", "Scrub agent env"),
|
|
300
307
|
("model", "Model"),
|
|
301
308
|
("fallback_model", "Fallback"),
|
|
302
309
|
("budget", "Budget"),
|
|
@@ -323,14 +330,14 @@ def _format_launch_settings_hint(name="<name>"):
|
|
|
323
330
|
)
|
|
324
331
|
|
|
325
332
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
326
|
-
if key in ("fast", "rtk", "logics", "notify", "notify_preview"):
|
|
333
|
+
if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env"):
|
|
327
334
|
if launch.get(key) is True:
|
|
328
335
|
return _style("on", "32", use_color)
|
|
329
336
|
if launch.get(key) is False:
|
|
330
337
|
return _style("off", "2", use_color)
|
|
331
338
|
if key == "logics":
|
|
332
339
|
return _dim("auto", use_color)
|
|
333
|
-
if key in ("notify", "notify_preview"):
|
|
340
|
+
if key in ("notify", "notify_preview", "scrub_env"):
|
|
334
341
|
return _style("off", "2", use_color)
|
|
335
342
|
return _dim("default", use_color)
|
|
336
343
|
value = launch.get(key)
|
package/src/codex_usage.py
CHANGED
|
@@ -247,7 +247,7 @@ def _request_codex_app_server(auth_home, method, params, failure_reason, timeout
|
|
|
247
247
|
stdout=subprocess.PIPE,
|
|
248
248
|
stderr=subprocess.DEVNULL,
|
|
249
249
|
env=env,
|
|
250
|
-
text=True,
|
|
250
|
+
text=True, encoding="utf-8", errors="replace",
|
|
251
251
|
bufsize=1,
|
|
252
252
|
)
|
|
253
253
|
thread = threading.Thread(target=_reader_thread, args=(process.stdout, output), daemon=True)
|
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/runs.py
CHANGED
|
@@ -265,7 +265,7 @@ def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
|
|
|
265
265
|
# Surface as a CdxError so handle_run's own handler marks the already
|
|
266
266
|
# registered run failed, instead of letting a raw OSError escape as an
|
|
267
267
|
# unhandled traceback on a --json call.
|
|
268
|
-
raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
|
|
268
|
+
raise CdxError(f"Failed to start detached cdx run: {error}", 126, code="provider_start_failed") from error
|
|
269
269
|
return {
|
|
270
270
|
"pid": getattr(child, "pid", None),
|
|
271
271
|
"launch_log_path": launch_log_path,
|
|
@@ -526,7 +526,7 @@ def handle_run(rest, ctx):
|
|
|
526
526
|
if not session:
|
|
527
527
|
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
528
528
|
if session.get("enabled", True) is False:
|
|
529
|
-
raise CdxError(f"Session is disabled: {parsed['name']}")
|
|
529
|
+
raise CdxError(f"Session is disabled: {parsed['name']}", code="session_disabled")
|
|
530
530
|
else:
|
|
531
531
|
selected = _select_headless_session(
|
|
532
532
|
ctx,
|
|
@@ -552,7 +552,7 @@ def handle_run(rest, ctx):
|
|
|
552
552
|
|
|
553
553
|
cwd = os.path.abspath(parsed["cwd"])
|
|
554
554
|
if not os.path.isdir(cwd):
|
|
555
|
-
raise CdxError(f"Invalid cwd: {parsed['cwd']}")
|
|
555
|
+
raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
|
|
556
556
|
prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
|
|
557
557
|
_consume_detached_prompt_file(parsed, detached_run_id)
|
|
558
558
|
launch_updates = {}
|
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/config.py
CHANGED
|
@@ -81,4 +81,20 @@ def normalize_permission(value):
|
|
|
81
81
|
def get_cdx_home(env=None):
|
|
82
82
|
if env is None:
|
|
83
83
|
env = os.environ
|
|
84
|
-
|
|
84
|
+
# `or`, not a get() default: the default is evaluated eagerly, and
|
|
85
|
+
# Path.home() raises on Windows when USERPROFILE is missing.
|
|
86
|
+
return env.get("CDX_HOME") or str(Path.home() / ".cdx")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_on_off(value, label):
|
|
90
|
+
"""Shared by `cdx set` flags and stored launch settings."""
|
|
91
|
+
from .errors import CdxError
|
|
92
|
+
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return value
|
|
95
|
+
text = str(value).strip().lower()
|
|
96
|
+
if text in ("on", "true", "1", "yes"):
|
|
97
|
+
return True
|
|
98
|
+
if text in ("off", "false", "0", "no"):
|
|
99
|
+
return False
|
|
100
|
+
raise CdxError(f"Unsupported {label} value: {value} (on|off)")
|
package/src/context_store.py
CHANGED
|
@@ -90,7 +90,11 @@ def read_context_path(path):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def write_context(base_dir, content, cwd=None):
|
|
93
|
-
|
|
93
|
+
# Same lock as append: otherwise an append that read the old text can land
|
|
94
|
+
# after this write and resurrect it, or this write can drop the append.
|
|
95
|
+
path = get_context_path(base_dir, cwd)
|
|
96
|
+
with _append_lock(path), _registry_lock(path, timeout_seconds=10, label="memory"):
|
|
97
|
+
return write_context_path(path, content)
|
|
94
98
|
|
|
95
99
|
|
|
96
100
|
def write_context_path(path, content):
|
|
@@ -155,11 +159,12 @@ def clear_context(base_dir, cwd=None):
|
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
def clear_context_path(path):
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
with _append_lock(path), _registry_lock(path, timeout_seconds=10, label="memory"):
|
|
163
|
+
try:
|
|
164
|
+
os.remove(path)
|
|
165
|
+
removed = True
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
removed = False
|
|
163
168
|
return {"path": path, "removed": removed}
|
|
164
169
|
|
|
165
170
|
|
package/src/errors.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
class CdxError(Exception):
|
|
2
|
-
def __init__(self, message, exit_code=1):
|
|
2
|
+
def __init__(self, message, exit_code=1, *, code=None):
|
|
3
3
|
super().__init__(message)
|
|
4
4
|
self.exit_code = exit_code
|
|
5
|
+
# Machine-readable code for `cdx run --json`; when absent, callers fall
|
|
6
|
+
# back to classifying the message (run_command.run_cdx_error_code).
|
|
7
|
+
if code is not None:
|
|
8
|
+
self.code = code
|
|
5
9
|
|
|
6
10
|
|
|
7
11
|
class CdxArgumentError(CdxError):
|
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"))
|
|
@@ -160,7 +162,7 @@ def _run_version_command(command, env, spawn_sync=None):
|
|
|
160
162
|
[command, "--version"],
|
|
161
163
|
env=env,
|
|
162
164
|
capture_output=True,
|
|
163
|
-
text=True,
|
|
165
|
+
text=True, encoding="utf-8", errors="replace",
|
|
164
166
|
timeout=5,
|
|
165
167
|
check=False,
|
|
166
168
|
)
|
|
@@ -226,7 +228,7 @@ def _provider_help_text(command, env, spawn_sync=None):
|
|
|
226
228
|
text = f"{getattr(result, 'stdout', '') or ''}\n{getattr(result, 'stderr', '') or ''}"
|
|
227
229
|
else:
|
|
228
230
|
completed = subprocess.run(
|
|
229
|
-
[command, "--help"], env=env, capture_output=True, text=True, timeout=10, check=False,
|
|
231
|
+
[command, "--help"], env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, check=False,
|
|
230
232
|
)
|
|
231
233
|
text = f"{completed.stdout or ''}\n{completed.stderr or ''}"
|
|
232
234
|
except (OSError, subprocess.TimeoutExpired):
|
|
@@ -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)
|
|
@@ -212,8 +215,13 @@ def windows_aumid(env=None):
|
|
|
212
215
|
return _POWERSHELL_AUMID
|
|
213
216
|
|
|
214
217
|
|
|
218
|
+
# PowerShell also ends a single-quoted string on the typographic quotes
|
|
219
|
+
# U+2018..U+201B, so each must be doubled like ASCII ' (French "l’agent").
|
|
220
|
+
_POWERSHELL_QUOTES = "'\u2018\u2019\u201a\u201b"
|
|
221
|
+
|
|
222
|
+
|
|
215
223
|
def _escape_powershell(value):
|
|
216
|
-
return str(value)
|
|
224
|
+
return "".join(ch * 2 if ch in _POWERSHELL_QUOTES else ch for ch in str(value))
|
|
217
225
|
|
|
218
226
|
|
|
219
227
|
def _escape_applescript(value):
|
|
@@ -243,7 +251,7 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
|
|
|
243
251
|
"target_timestamp": target_timestamp,
|
|
244
252
|
}
|
|
245
253
|
|
|
246
|
-
argv = _scheduled_notify_argv(
|
|
254
|
+
argv = _scheduled_notify_argv(event, env)
|
|
247
255
|
if sys.platform == "darwin":
|
|
248
256
|
result = _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env)
|
|
249
257
|
elif sys.platform == "win32":
|
|
@@ -255,17 +263,11 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
|
|
|
255
263
|
return result
|
|
256
264
|
|
|
257
265
|
|
|
258
|
-
def _scheduled_notify_argv(
|
|
266
|
+
def _scheduled_notify_argv(event, env):
|
|
259
267
|
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
|
|
268
|
+
# `cdx notify` is the agent-hook target now and stays silent without a hook
|
|
269
|
+
# payload, so the job names the notification it has to show.
|
|
270
|
+
return [executable, "ready", "--fire", event.get("session") or "cdx"]
|
|
269
271
|
|
|
270
272
|
|
|
271
273
|
def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
|
|
@@ -273,7 +275,7 @@ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
|
|
|
273
275
|
schedule_dir = os.path.join(base_dir, "state", "notifications")
|
|
274
276
|
os.makedirs(schedule_dir, mode=0o700, exist_ok=True)
|
|
275
277
|
script_path = os.path.join(schedule_dir, f"{label}.sh")
|
|
276
|
-
launch_agents = os.path.join(
|
|
278
|
+
launch_agents = os.path.join(_operator_home(), "Library", "LaunchAgents")
|
|
277
279
|
os.makedirs(launch_agents, exist_ok=True)
|
|
278
280
|
plist_path = os.path.join(launch_agents, f"{label}.plist")
|
|
279
281
|
target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
|
|
@@ -298,6 +300,14 @@ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
|
|
|
298
300
|
return {"scheduled": True, "existing": False, "backend": "launchd", "id": label, "path": plist_path}
|
|
299
301
|
|
|
300
302
|
|
|
303
|
+
def _operator_home():
|
|
304
|
+
"""The operator's real home. Inside a cdx session HOME is the profile home,
|
|
305
|
+
whose LaunchAgents folder launchd never reads."""
|
|
306
|
+
import pwd
|
|
307
|
+
|
|
308
|
+
return pwd.getpwuid(os.getuid()).pw_dir
|
|
309
|
+
|
|
310
|
+
|
|
301
311
|
def _macos_schedule_script(argv, env, label, plist_path, script_path):
|
|
302
312
|
lines = ["#!/bin/sh", f"export PATH={shlex.quote(env.get('PATH', '/usr/local/bin:/usr/bin:/bin'))}"]
|
|
303
313
|
if env.get("CDX_HOME"):
|
|
@@ -365,23 +375,23 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
|
365
375
|
def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
|
|
366
376
|
name = _schedule_id("cdx-manager-notify", parsed, event)
|
|
367
377
|
target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
"
|
|
377
|
-
|
|
378
|
-
"
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
|
|
378
|
+
# Not `schtasks /SD`: it reads the date in the user's locale format, so
|
|
379
|
+
# mm/dd/yyyy is refused on a dd/MM/yyyy (French) Windows. An invariant
|
|
380
|
+
# ISO date through Register-ScheduledTask works on every locale.
|
|
381
|
+
start = target.strftime("%Y-%m-%dT%H:%M")
|
|
382
|
+
end = (target + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
|
|
383
|
+
arguments = subprocess.list2cmdline([str(part) for part in argv[1:]])
|
|
384
|
+
script = (
|
|
385
|
+
"$c = [Globalization.CultureInfo]::InvariantCulture; "
|
|
386
|
+
f"$t = New-ScheduledTaskTrigger -Once -At ([datetime]::ParseExact('{start}', 'yyyy-MM-ddTHH:mm', $c)); "
|
|
387
|
+
f"$t.EndBoundary = ([datetime]::ParseExact('{end}', 'yyyy-MM-ddTHH:mm', $c)).ToString('s'); "
|
|
388
|
+
f"$a = New-ScheduledTaskAction -Execute '{_escape_powershell(argv[0])}' -Argument '{_escape_powershell(arguments)}'; "
|
|
389
|
+
"$s = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 1); "
|
|
390
|
+
f"Register-ScheduledTask -TaskName '{_escape_powershell(name)}' -Action $a -Trigger $t -Settings $s -Force | Out-Null"
|
|
391
|
+
)
|
|
392
|
+
result = _run_scheduler_command(
|
|
393
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script], spawn_sync, env,
|
|
394
|
+
)
|
|
385
395
|
if not result["ok"]:
|
|
386
396
|
raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
|
|
387
397
|
return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
|