cdx-manager 0.18.4 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -595,11 +595,15 @@ def _align_tray_companion(ctx, target_version):
595
595
  warning, not a reason to call a good update bad.
596
596
  """
597
597
  from ..tray_install import align_companion
598
- from ..tray_instance import companion_instance
599
598
 
600
599
  try:
601
600
  base_dir = ctx["service"]["base_dir"]
602
- result = align_companion(base_dir, target_version, download=ctx.get("download_asset"))
601
+ result = align_companion(
602
+ base_dir, target_version,
603
+ download=ctx.get("download_asset"),
604
+ env=ctx.get("env"),
605
+ spawn=ctx.get("spawn_detached"),
606
+ )
603
607
  except Exception as error: # noqa: BLE001 - the update already succeeded
604
608
  return [{"code": "tray_companion_not_aligned", "message": f"The tray companion was left as it was: {error}"}]
605
609
 
@@ -614,21 +618,26 @@ def _align_tray_companion(ctx, target_version):
614
618
  ),
615
619
  }]
616
620
 
621
+ if result.get("restarted"):
622
+ return [{
623
+ "code": "tray_companion_updated",
624
+ "message": f"Updated the tray companion to {target_version} and restarted it.",
625
+ }]
626
+
617
627
  warnings = [{
618
628
  "code": "tray_companion_updated",
619
629
  "message": f"Updated the tray companion to {target_version} as well.",
620
630
  }]
621
- # A running companion keeps executing the binary it started with. Saying so
622
- # beats leaving someone to wonder why the menu still looks old, and beats
623
- # quitting an application they did not ask us to touch.
624
- try:
625
- if companion_instance(env=ctx.get("env")).get("pid"):
626
- warnings.append({
627
- "code": "tray_companion_restart_pending",
628
- "message": "The running tray is still the previous build. Quit it from its menu and run: cdx tray launch",
629
- })
630
- except Exception: # noqa: BLE001
631
- pass
631
+ # It was replaced but is not running. Only worth saying when there was one
632
+ # to bring back: a user who had no tray open did not lose anything.
633
+ if result.get("restart_reason") and result.get("restart_reason") != "no tray was running":
634
+ warnings.append({
635
+ "code": "tray_companion_restart_pending",
636
+ "message": (
637
+ f"The tray companion did not restart: {result['restart_reason']} "
638
+ "Run: cdx tray launch"
639
+ ),
640
+ })
632
641
  return warnings
633
642
 
634
643
 
@@ -42,10 +42,13 @@ from ..tray_install import (
42
42
  uninstall,
43
43
  )
44
44
  from ..tray_instance import companion_instance
45
+ from ..tray_plugins import ADAPTERS, collect_cards, enabled_plugins, set_plugin_enabled
46
+ from ..tray_terminal import REFUSAL as TERMINAL_REFUSAL
47
+ from ..tray_terminal import clear_terminal, set_terminal, terminal_preference
45
48
 
46
49
  TRAY_ACTIONS = (
47
50
  "status", "install", "launch", "uninstall", "autostart", "doctor",
48
- "events", "ack", "heartbeat", "alerts",
51
+ "events", "ack", "heartbeat", "alerts", "plugin", "terminal",
49
52
  )
50
53
 
51
54
  # Codes rather than prose, so a caller can branch on them.
@@ -86,9 +89,23 @@ def handle_tray(rest, ctx):
86
89
  return _tray_heartbeat(args, ctx)
87
90
  if action == "alerts":
88
91
  return _tray_alerts(args, ctx)
92
+ if action == "plugin":
93
+ return _tray_plugin(args, ctx)
94
+ if action == "terminal":
95
+ return _tray_terminal(args, ctx)
89
96
  return _tray_install(args, ctx)
90
97
 
91
98
 
99
+ def _plugin_cache(ctx):
100
+ """The per-process card cache.
101
+
102
+ `cdx tray status` is a fresh process per poll, so this only helps within one
103
+ invocation today. It exists as the seam the TTL is honoured through, and the
104
+ place a longer-lived cache would go without every adapter learning about it.
105
+ """
106
+ return ctx.setdefault("plugin_cache", {})
107
+
108
+
92
109
  def _tray_status(args, ctx):
93
110
  parsed = _parse_flag_args(args, {
94
111
  "--json": {"key": "json", "type": "bool", "default": False},
@@ -103,11 +120,21 @@ def _tray_status(args, ctx):
103
120
  force_refresh=parsed["refresh"],
104
121
  cache_only=not parsed["refresh"],
105
122
  )
123
+ # Enabled integrations contribute a card each. A manual refresh passes no
124
+ # cache, so it is what actually re-asks them; a poll reuses a card for its
125
+ # TTL and does not make an integration pay the tray's rate.
126
+ cards = collect_cards(
127
+ ctx["service"]["base_dir"],
128
+ env=ctx.get("env"),
129
+ cache=None if parsed["refresh"] else _plugin_cache(ctx),
130
+ )
106
131
  snapshot = build_snapshot(
107
132
  rows,
108
133
  datetime.now(timezone.utc),
109
134
  ctx["version"],
110
135
  refreshable=not any(row.get("active") for row in rows),
136
+ plugins=cards,
137
+ terminal=terminal_preference(ctx["service"]["base_dir"]),
111
138
  )
112
139
  # Pending alerts ride along with the snapshot rather than costing their own
113
140
  # call. Across WSL every invocation is a `wsl.exe` crossing measured at
@@ -210,11 +237,63 @@ def _confirm_autostart():
210
237
  return answer.strip().lower() in ("", "y", "yes")
211
238
 
212
239
 
240
+ def _confirm_alerts():
241
+ answer = input("Notify you when an agent finishes a turn or waits for you? [Y/n] ")
242
+ return answer.strip().lower() in ("", "y", "yes")
243
+
244
+
245
+ def _accepted(parsed, ctx, on_flag, off_flag, confirm):
246
+ """One consent, resolved the same way for each question.
247
+
248
+ Two rules make it predictable. An explicit flag always wins, so a script
249
+ gets what it asked for and never a prompt. And in the absence of one, only
250
+ an interactive human-readable run asks — a piped or `--json` invocation
251
+ declines rather than deciding on someone's behalf.
252
+ """
253
+ if parsed[off_flag]:
254
+ return False, f"skipped with --{off_flag.replace('_', '-')}"
255
+ if parsed[on_flag] or parsed["yes"]:
256
+ return True, None
257
+ if ctx["stdin_is_tty"] and not parsed["json"] and confirm():
258
+ return True, None
259
+ return False, None
260
+
261
+
262
+ def _enable_alerts_everywhere(ctx):
263
+ """Turn alerts on for every session that can have them, and for future ones.
264
+
265
+ The two halves are one promise: a user who accepted alerts means the tray
266
+ tells them when an agent needs them, not that it does so for the sessions
267
+ that happened to exist at the moment they answered.
268
+
269
+ Providers with no hook mechanism are left alone rather than written to with
270
+ a setting they will ignore.
271
+ """
272
+ from ..agent_notify import supports_agent_alerts
273
+ from ..tray_defaults import set_alerts_default
274
+
275
+ service = ctx["service"]
276
+ set_alerts_default(service["base_dir"], True)
277
+ enabled, codex = [], False
278
+ for session in service["list_sessions"]():
279
+ if not supports_agent_alerts(session["provider"]):
280
+ continue
281
+ if (session.get("launch") or {}).get("notify") is True:
282
+ continue
283
+ service["set_launch_settings"](session["name"], {"notify": True})
284
+ enabled.append(session["name"])
285
+ codex = codex or session["provider"] == "codex"
286
+ return {"sessions": enabled, "codex_trust_required": codex}
287
+
288
+
213
289
  def _tray_install(args, ctx):
214
290
  parsed = _parse_flag_args(args, {
215
291
  "--json": {"key": "json", "type": "bool", "default": False},
216
292
  "--yes": {"key": "yes", "type": "bool", "default": False},
293
+ "--autostart": {"key": "autostart", "type": "bool", "default": False},
217
294
  "--no-autostart": {"key": "no_autostart", "type": "bool", "default": False},
295
+ "--alerts": {"key": "alerts", "type": "bool", "default": False},
296
+ "--no-alerts": {"key": "no_alerts", "type": "bool", "default": False},
218
297
  }, TRAY_USAGE, positionals_key="args", max_positionals=0)
219
298
  env = ctx.get("env")
220
299
  target = current_target()
@@ -255,24 +334,41 @@ def _tray_install(args, ctx):
255
334
  # what people resent about desktop software. Asking keeps the reason and
256
335
  # drops the friction; a non-interactive run declines rather than deciding.
257
336
  autostart_on = False
258
- autostart_reason = None
259
- if parsed["no_autostart"]:
260
- autostart_reason = "skipped with --no-autostart"
261
- elif parsed["yes"] or (ctx["stdin_is_tty"] and not parsed["json"] and _confirm_autostart()):
337
+ wanted, autostart_reason = _accepted(parsed, ctx, "autostart", "no_autostart", _confirm_autostart)
338
+ if wanted:
262
339
  try:
263
340
  autostart_enable(state["executable"], env=env, run=ctx.get("spawn_sync"))
264
341
  autostart_on = True
265
342
  except Exception as error: # noqa: BLE001
266
343
  autostart_reason = str(error)
267
- else:
344
+ elif autostart_reason is None:
268
345
  autostart_reason = "not enabled; run: cdx tray autostart on"
269
346
 
347
+ # The second question, asked separately because it is a different decision
348
+ # with a different cost: one adds a login item, the other writes hooks into
349
+ # each provider's own configuration.
350
+ alerts_on = False
351
+ alerts_reason = None
352
+ alerts_result = {"sessions": [], "codex_trust_required": False}
353
+ wanted, alerts_reason = _accepted(parsed, ctx, "alerts", "no_alerts", _confirm_alerts)
354
+ if wanted:
355
+ try:
356
+ alerts_result = _enable_alerts_everywhere(ctx)
357
+ alerts_on = True
358
+ except Exception as error: # noqa: BLE001 - the install itself succeeded
359
+ alerts_reason = str(error)
360
+ elif alerts_reason is None:
361
+ alerts_reason = "not enabled; run: cdx set --all --notify on"
362
+
270
363
  if parsed["json"]:
271
364
  _write_json(ctx, _json_success(
272
365
  "tray.install", message,
273
366
  target=state["target"], executable=state["executable"],
274
367
  sha256=state["sha256"], applied=True,
275
368
  started=started, autostart=autostart_on,
369
+ alerts=alerts_on,
370
+ alerts_sessions=alerts_result["sessions"],
371
+ codex_trust_required=alerts_result["codex_trust_required"],
276
372
  ))
277
373
  return 0
278
374
  ctx["out"](f"{message}\n{_dim(state['executable'], ctx['use_color'])}\n")
@@ -280,6 +376,149 @@ def _tray_install(args, ctx):
280
376
  ctx["out"](
281
377
  f"{'It will start at login.' if autostart_on else f'Startup: {autostart_reason}'}\n"
282
378
  )
379
+ if alerts_on:
380
+ count = len(alerts_result["sessions"])
381
+ covered = f"{count} session(s)" if count else "no existing session"
382
+ ctx["out"](
383
+ f"Agent alerts: on for {covered}, and for new ones — "
384
+ "hooks install at each session's next launch.\n"
385
+ )
386
+ if alerts_result["codex_trust_required"]:
387
+ # Said here rather than discovered later: Codex puts an approval
388
+ # prompt in front of the user at the next launch, and cdx will not
389
+ # write that trust on their behalf.
390
+ ctx["out"]("Codex will ask you once to approve the cdx hook plugin.\n")
391
+ else:
392
+ ctx["out"](f"Agent alerts: {alerts_reason}\n")
393
+ return 0
394
+
395
+
396
+ PLUGIN_UNKNOWN = "tray_plugin_unknown"
397
+ TERMINAL_INVALID = "tray_terminal_invalid"
398
+
399
+
400
+ def _tray_terminal(args, ctx):
401
+ """Choose the terminal a session row opens, or go back to the default.
402
+
403
+ A refusal is a refusal, not a silent normalisation: a value that cannot be
404
+ stored says why, and nothing is written. Storing a sanitized version of what
405
+ someone typed would make the rule invisible exactly where it matters.
406
+ """
407
+ parsed = _parse_flag_args(args, {
408
+ "--json": {"key": "json", "type": "bool", "default": False},
409
+ }, TRAY_USAGE, positionals_key="args", max_positionals=2)
410
+ positional = parsed["args"] or ["status"]
411
+ mode = positional[0]
412
+ base_dir = ctx["service"]["base_dir"]
413
+
414
+ if mode == "clear":
415
+ clear_terminal(base_dir)
416
+ elif mode == "set":
417
+ if len(positional) < 2:
418
+ raise CdxError(TRAY_USAGE)
419
+ try:
420
+ set_terminal(base_dir, positional[1])
421
+ except ValueError:
422
+ return _refuse(ctx, parsed["json"], "tray.terminal", TERMINAL_INVALID, TERMINAL_REFUSAL)
423
+ elif mode != "status":
424
+ raise CdxError(TRAY_USAGE)
425
+
426
+ chosen = terminal_preference(base_dir)
427
+ message = (
428
+ f"Tray session rows open {chosen}." if chosen
429
+ else "Tray session rows open this platform's default terminal."
430
+ )
431
+ if parsed["json"]:
432
+ _write_json(ctx, _json_success(
433
+ "tray.terminal", message, terminal=chosen, applied=mode in ("set", "clear"),
434
+ ))
435
+ return 0
436
+ ctx["out"](f"{message}\n")
437
+ if chosen:
438
+ # Said once, at the moment it is chosen: the companion falls back
439
+ # silently, and a user whose terminal never opens should know that is
440
+ # the designed behaviour rather than a broken setting.
441
+ ctx["out"](f"{_dim('If it is not installed, the platform default opens instead.', ctx['use_color'])}\n")
442
+ return 0
443
+
444
+
445
+ def _tray_plugin(args, ctx):
446
+ """Turn an integration on or off, and say what is on.
447
+
448
+ Explicit in both directions and reversible, because an integration runs
449
+ another tool's command on the user's behalf: nothing here has a default that
450
+ turns something on, and `disable` is not a hidden setting.
451
+ """
452
+ parsed = _parse_flag_args(args, {
453
+ "--json": {"key": "json", "type": "bool", "default": False},
454
+ }, TRAY_USAGE, positionals_key="args", max_positionals=2)
455
+ positional = parsed["args"] or ["status"]
456
+ mode = positional[0]
457
+ if mode not in ("enable", "disable", "status", "run"):
458
+ raise CdxError(TRAY_USAGE)
459
+ base_dir = ctx["service"]["base_dir"]
460
+
461
+ if mode == "run":
462
+ return _tray_plugin_run(positional[1:], parsed, ctx)
463
+
464
+ if mode in ("enable", "disable"):
465
+ if len(positional) < 2:
466
+ raise CdxError(TRAY_USAGE)
467
+ name = positional[1]
468
+ if name not in ADAPTERS:
469
+ return _refuse(
470
+ ctx, parsed["json"], "tray.plugin", PLUGIN_UNKNOWN,
471
+ f"No tray integration is called {name}. Known: {', '.join(sorted(ADAPTERS))}.",
472
+ )
473
+ set_plugin_enabled(base_dir, name, mode == "enable")
474
+
475
+ enabled = enabled_plugins(base_dir)
476
+ available = _available_plugins(ctx, enabled)
477
+ message = (
478
+ f"Tray integrations enabled: {', '.join(enabled)}" if enabled
479
+ else "No tray integration is enabled."
480
+ )
481
+ if parsed["json"]:
482
+ _write_json(ctx, _json_success(
483
+ "tray.plugin", message, enabled=enabled, known=sorted(ADAPTERS),
484
+ activatable=available, applied=mode in ("enable", "disable"),
485
+ ))
486
+ return 0
487
+ ctx["out"](f"{message}\n")
488
+ for name in available:
489
+ # Said once, as an offer rather than a nudge repeated on every command:
490
+ # the tool is here, the card is not on, and this is how to turn it on.
491
+ ctx["out"](f"{_dim(f'{name} is installed but not enabled. Run: cdx tray plugin enable {name}', ctx['use_color'])}\n")
492
+ return 0
493
+
494
+
495
+ def _available_plugins(ctx, enabled):
496
+ """Integrations whose tool is installed and whose card is not enabled."""
497
+ from ..logics_view import resolve_logics_manager
498
+
499
+ if "logics" in enabled or not resolve_logics_manager(ctx.get("env") or {}):
500
+ return []
501
+ return ["logics"]
502
+
503
+
504
+ def _tray_plugin_run(rest, parsed, ctx):
505
+ """Perform one card action, named by its id.
506
+
507
+ The tray sends an id back rather than a command, and this is where that
508
+ matters: the id is matched against what CDX knows how to do, and anything
509
+ else is refused. There is no path from a card to a shell.
510
+ """
511
+ from ..tray_plugin_actions import perform_action
512
+
513
+ if not rest:
514
+ raise CdxError(TRAY_USAGE)
515
+ result = perform_action(rest[0], ctx)
516
+ if not result["ok"]:
517
+ return _refuse(ctx, parsed["json"], "tray.plugin.run", result["code"], result["message"])
518
+ if parsed["json"]:
519
+ _write_json(ctx, _json_success("tray.plugin.run", result["message"], applied=True))
520
+ return 0
521
+ ctx["out"](f"{result['message']}\n")
283
522
  return 0
284
523
 
285
524
 
@@ -614,6 +614,28 @@ def rename_session(base_dir, store, source_name, dest_name):
614
614
  return result["session"]
615
615
 
616
616
 
617
+ def _launch_defaults_for(base_dir, provider):
618
+ """The launch settings a new session starts with.
619
+
620
+ Agent alerts are the one setting that can be inherited, and only from an
621
+ explicit answer given during `cdx tray install`. Without this, accepting
622
+ alerts once covered the sessions that existed at that moment and silently
623
+ excluded every session created afterwards.
624
+ """
625
+ from .agent_notify import supports_agent_alerts
626
+ from .tray_defaults import alerts_default
627
+
628
+ launch = dict(DEFAULT_LAUNCH_SETTINGS)
629
+ try:
630
+ if supports_agent_alerts(provider) and alerts_default(base_dir):
631
+ launch["notify"] = True
632
+ except OSError:
633
+ # An unreadable default is no default. Creating the session matters
634
+ # more than inheriting a preference.
635
+ pass
636
+ return launch
637
+
638
+
617
639
  def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
618
640
  _validate_new_session_name(name)
619
641
  normalized_provider = _normalize_provider(provider)
@@ -639,7 +661,7 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
639
661
  "lastLaunchedAt": None,
640
662
  "lastStatusAt": None,
641
663
  "lastStatus": None,
642
- "launch": dict(DEFAULT_LAUNCH_SETTINGS),
664
+ "launch": _launch_defaults_for(base_dir, normalized_provider),
643
665
  "auth": {
644
666
  "status": "unknown",
645
667
  "lastCheckedAt": None,
@@ -23,7 +23,7 @@ from .status_view import _format_reset_time
23
23
 
24
24
  SCHEMA_NAME = "cdx.tray.snapshot"
25
25
  SCHEMA_MAJOR = 1
26
- SCHEMA_MINOR = 0
26
+ SCHEMA_MINOR = 2
27
27
 
28
28
  # Below this much remaining capacity a session cannot usefully take work. Shared
29
29
  # with the ranking's own usability threshold so the tray and `cdx next` do not
@@ -223,7 +223,7 @@ def tooltip_for(worst):
223
223
  return "CDX · " + " · ".join(parts)
224
224
 
225
225
 
226
- def build_snapshot(rows, now, cdx_version, refreshable=True):
226
+ def build_snapshot(rows, now, cdx_version, refreshable=True, plugins=None, terminal=None):
227
227
  """The whole contract, from the rows `cdx status` already returns.
228
228
 
229
229
  `icon` carries no session name, account, or figure: it is what shows while
@@ -254,6 +254,15 @@ def build_snapshot(rows, now, cdx_version, refreshable=True):
254
254
  "sessions": sessions,
255
255
  "refreshable": bool(refreshable),
256
256
  "actions": ["refresh", "open_terminal"],
257
+ # Zero or more bounded cards from enabled integrations. A minor-version
258
+ # addition: a companion that predates it ignores the key and shows
259
+ # exactly what it showed before.
260
+ "plugins": list(plugins or []),
261
+ # Which terminal a session row should open, or None for the platform's
262
+ # own. Carried here rather than kept by the companion, because every
263
+ # other durable tray preference already lives in CDX and a second store
264
+ # is a second thing to keep in step.
265
+ "terminal": terminal,
257
266
  }
258
267
 
259
268
 
@@ -0,0 +1,48 @@
1
+ """What a newly created session inherits, once the user has said yes once.
2
+
3
+ Accepting agent alerts during `cdx tray install` has to mean two different
4
+ things, and only saying one of them is what made the feature feel broken: the
5
+ sessions that exist today get alerts, and the ones created tomorrow get them
6
+ too. Without the second, a user who accepted alerts finds their next session
7
+ silent and has no reason to suspect a per-session opt-in they never saw.
8
+
9
+ Stored beside the mute state rather than in the session file, because it is not
10
+ a property of any session: it is the answer to a question asked once, and a
11
+ session created later has to be able to read it.
12
+ """
13
+ import json
14
+ import os
15
+
16
+ STATE_VERSION = 1
17
+
18
+
19
+ def state_path(base_dir):
20
+ return os.path.join(base_dir, "tray", "defaults.json")
21
+
22
+
23
+ def alerts_default(base_dir):
24
+ """Whether a new supported session starts with alerts on.
25
+
26
+ Off unless explicitly accepted. Anything unreadable, unrecognised or from a
27
+ version this build does not know reads as off: inheriting an opt-in from a
28
+ file we cannot understand would enable provider hooks nobody agreed to.
29
+ """
30
+ try:
31
+ with open(state_path(base_dir), encoding="utf-8") as handle:
32
+ state = json.load(handle)
33
+ except (OSError, ValueError):
34
+ return False
35
+ if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
36
+ return False
37
+ return state.get("notify") is True
38
+
39
+
40
+ def set_alerts_default(base_dir, enabled):
41
+ """Record the answer, and report what it now is."""
42
+ path = state_path(base_dir)
43
+ os.makedirs(os.path.dirname(path), exist_ok=True)
44
+ temp = f"{path}.{os.getpid()}.tmp"
45
+ with open(temp, "w", encoding="utf-8") as handle:
46
+ json.dump({"version": STATE_VERSION, "notify": bool(enabled)}, handle, indent=2)
47
+ os.replace(temp, path)
48
+ return bool(enabled)
@@ -101,12 +101,18 @@ def tray_is_listening(base_dir, now=None):
101
101
 
102
102
  # --- the hook's side -------------------------------------------------------
103
103
 
104
- def publish(base_dir, title, message, kind="complete", now=None, event_id=None):
104
+ def publish(base_dir, title, message, kind="complete", now=None, event_id=None, details=None):
105
105
  """Hand one event to the tray. Returns True only if it was written.
106
106
 
107
107
  False means the caller must deliver it directly, so every failure here has
108
108
  to be a False and never an exception: the alternative is a provider hook
109
109
  that raises inside someone's agent turn.
110
+
111
+ `details` carries the same event as fields, so a companion does not have to
112
+ read a sentence to know which session it belongs to. It is written beside
113
+ the rendered title and message, never instead of them: a companion that
114
+ predates the fields ignores an unknown key and shows what it always showed,
115
+ and a newer one handed a malformed `details` still has the sentence.
110
116
  """
111
117
  now = time.time() if now is None else now
112
118
  try:
@@ -119,6 +125,8 @@ def publish(base_dir, title, message, kind="complete", now=None, event_id=None):
119
125
  "title": title,
120
126
  "message": message,
121
127
  }
128
+ if isinstance(details, dict) and details:
129
+ event["details"] = details
122
130
  path = events_path(base_dir)
123
131
  os.makedirs(os.path.dirname(path), exist_ok=True)
124
132
  line = json.dumps(event, ensure_ascii=False) + "\n"