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/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
- text = str(value).strip().lower()
218
- if text in ("on", "true", "1", "yes"):
219
- return True
220
- if text in ("off", "false", "0", "no"):
221
- return False
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": _parse_fast_value},
252
- "--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
253
- "--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
254
- "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _parse_fast_value},
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
 
@@ -9,8 +9,9 @@ import asyncio
9
9
  import json
10
10
  import os
11
11
  import re
12
- from datetime import datetime
12
+ from datetime import datetime, timezone
13
13
 
14
+ from .claude_credentials import delete_keychain_credentials
14
15
  from .cli_render import _dim, _info, _pad_table, _style, _warn
15
16
  from .errors import CdxError
16
17
  from .fs_utils import atomic_write
@@ -237,6 +238,8 @@ def _write_claude_oauth_token(auth_home, token):
237
238
  "version": "1.0",
238
239
  "type": "oauth_token",
239
240
  "access_token": token,
241
+ # Read by claude_credentials.setup_token_expires_at for the expiry warning.
242
+ "created_at": datetime.now(timezone.utc).isoformat(),
240
243
  }
241
244
  # atomic_write keeps the token 0o600 from creation — no umask window.
242
245
  atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
@@ -244,7 +247,7 @@ def _write_claude_oauth_token(auth_home, token):
244
247
 
245
248
  def _bootstrap_claude_setup_token(session, ctx):
246
249
  ctx["out"](
247
- "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
250
+ "Minting a long-lived Claude setup-token for this session.\n"
248
251
  )
249
252
  run_info = _run_interactive_provider_command(
250
253
  session,
@@ -277,11 +280,14 @@ def _bootstrap_claude_setup_token(session, ctx):
277
280
  )
278
281
  auth_home = session.get("authHome") or ""
279
282
  cred_path = _write_claude_oauth_token(auth_home, token)
280
- # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
283
+ # A profile /login wins over the setup-token at launch (see
284
+ # provider_runtime._read_claude_launch_oauth_token), so drop it on every
285
+ # platform: the file on Linux/Windows, the profile keychain entry on macOS.
281
286
  try:
282
287
  os.remove(os.path.join(auth_home, ".claude", ".credentials.json"))
283
288
  except OSError:
284
289
  pass
290
+ delete_keychain_credentials(auth_home)
285
291
  return cred_path
286
292
 
287
293
  def _format_launch_config(session, use_color=False):
@@ -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)
@@ -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)
@@ -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),
@@ -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 = {}
@@ -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
- return env.get("CDX_HOME", str(Path.home() / ".cdx"))
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)")
@@ -90,7 +90,11 @@ def read_context_path(path):
90
90
 
91
91
 
92
92
  def write_context(base_dir, content, cwd=None):
93
- return write_context_path(get_context_path(base_dir, cwd), content)
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
- try:
159
- os.remove(path)
160
- removed = True
161
- except FileNotFoundError:
162
- removed = False
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).replace("'", "''")
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(parsed, env)
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(parsed, env):
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
- argv = [executable, "notify"]
261
- if parsed["mode"] == "at-reset":
262
- argv.append(parsed["name"])
263
- argv.append("--at-reset")
264
- else:
265
- argv.append("--next-ready")
266
- argv.append("--once")
267
- argv.append("--refresh")
268
- return argv
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(os.path.expanduser("~"), "Library", "LaunchAgents")
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
- command = subprocess.list2cmdline([str(part) for part in argv])
369
- result = _run_scheduler_command([
370
- "schtasks",
371
- "/Create",
372
- "/SC",
373
- "ONCE",
374
- "/TN",
375
- name,
376
- "/TR",
377
- command,
378
- "/ST",
379
- target.strftime("%H:%M"),
380
- "/SD",
381
- target.strftime("%m/%d/%Y"),
382
- "/F",
383
- "/Z",
384
- ], spawn_sync, env)
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}
@@ -107,7 +107,7 @@ def _default_spawn_sync(argv, env=None):
107
107
  completed = subprocess.run(
108
108
  argv,
109
109
  capture_output=True,
110
- text=True,
110
+ text=True, encoding="utf-8", errors="replace",
111
111
  timeout=_CAPABILITY_PROBE_TIMEOUT_SECONDS,
112
112
  env=env,
113
113
  check=False,