cdx-manager 0.18.3 → 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.
package/src/cli.py CHANGED
@@ -84,7 +84,7 @@ from .status_view import (
84
84
  from .update_check import check_for_update, check_logics_manager_for_update
85
85
 
86
86
 
87
- def _resolve_version():
87
+ def _resolve_version(package_root=None, metadata_lookup=None):
88
88
  """The version, resolved rather than restated.
89
89
 
90
90
  This was a hardcoded string, a fourth copy alongside VERSION, package.json,
@@ -95,23 +95,38 @@ def _resolve_version():
95
95
  happens in a checkout, where it is the thing being edited and the installed
96
96
  metadata is whatever was last `pip install`ed. Installed packages have no
97
97
  such file and fall through to their own metadata.
98
+
99
+ An npm install has neither: no Python distribution metadata, and — until
100
+ VERSION was added to the published `files` list — no VERSION either. That
101
+ combination reported 0.0.0, which reads as permanently out of date and makes
102
+ `cdx update` succeed without ever silencing itself. package.json ships in
103
+ every npm tarball by construction, so reading it is the branch that cannot
104
+ go missing again.
98
105
  """
99
- version_file = os.path.join(
100
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "VERSION"
101
- )
106
+ if package_root is None:
107
+ package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
102
108
  try:
103
- with open(version_file, encoding="utf-8") as handle:
109
+ with open(os.path.join(package_root, "VERSION"), encoding="utf-8") as handle:
104
110
  text = handle.read().strip()
105
111
  if text:
106
112
  return text
107
113
  except OSError:
108
114
  pass
109
115
  try:
110
- from importlib.metadata import version
116
+ if metadata_lookup is None:
117
+ from importlib.metadata import version as metadata_lookup
111
118
 
112
- return version("cdx-manager")
113
- except Exception: # noqa: BLE001 - any resolution failure falls back below
114
- return "0.0.0"
119
+ return metadata_lookup("cdx-manager")
120
+ except Exception: # noqa: BLE001 - not installed as a Python distribution
121
+ pass
122
+ try:
123
+ with open(os.path.join(package_root, "package.json"), encoding="utf-8") as handle:
124
+ text = str(json.load(handle).get("version") or "").strip()
125
+ if text:
126
+ return text
127
+ except (OSError, ValueError):
128
+ pass
129
+ return "0.0.0"
115
130
 
116
131
 
117
132
  VERSION = _resolve_version()
package/src/cli_args.py CHANGED
@@ -63,7 +63,7 @@ RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
63
63
  RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
64
64
  RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
65
65
  SCHEMA_USAGE = "Usage: cdx schema --json"
66
- TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install|launch|uninstall|doctor [--json] | cdx tray autostart [on|off|status] [--json] | cdx tray events [--json] | cdx tray ack <id>... [--json] | cdx tray heartbeat [--json] | cdx tray alerts [on|off|status] [--json]"
66
+ TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install [--json] [--yes] [--autostart|--no-autostart] [--alerts|--no-alerts] | cdx tray launch|uninstall|doctor [--json] | cdx tray autostart [on|off|status] [--json] | cdx tray events [--json] | cdx tray ack <id>... [--json] | cdx tray heartbeat [--json] | cdx tray alerts [on|off|status] [--json] | cdx tray plugin [status|enable <name>|disable <name>|run <action>] [--json] | cdx tray terminal [status|set <app>|clear] [--json]"
67
67
 
68
68
  RUN_TAIL_DEFAULT_LINES = 50
69
69
  RUN_TAIL_MAX_LINES = 2000
@@ -587,6 +587,60 @@ def handle_repair(rest, ctx):
587
587
  ctx["out"](f"{_dim('Tip: run cdx repair --force to apply safe repairs.', ctx['use_color'])}\n")
588
588
  return 0
589
589
 
590
+ def _align_tray_companion(ctx, target_version):
591
+ """Move an installed tray companion to the release CDX just moved to.
592
+
593
+ Never raises and never fails the update: CDX has already been replaced
594
+ successfully by this point, and a companion that could not follow is a
595
+ warning, not a reason to call a good update bad.
596
+ """
597
+ from ..tray_install import align_companion
598
+
599
+ try:
600
+ base_dir = ctx["service"]["base_dir"]
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
+ )
607
+ except Exception as error: # noqa: BLE001 - the update already succeeded
608
+ return [{"code": "tray_companion_not_aligned", "message": f"The tray companion was left as it was: {error}"}]
609
+
610
+ if not result["aligned"]:
611
+ if result.get("reason") in ("no companion is installed", "already aligned"):
612
+ return []
613
+ return [{
614
+ "code": "tray_companion_not_aligned",
615
+ "message": (
616
+ f"The tray companion is still on {result.get('previous') or 'its previous version'}: "
617
+ f"{result['reason']} Run: cdx tray install"
618
+ ),
619
+ }]
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
+
627
+ warnings = [{
628
+ "code": "tray_companion_updated",
629
+ "message": f"Updated the tray companion to {target_version} as well.",
630
+ }]
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
+ })
641
+ return warnings
642
+
643
+
590
644
  def handle_update(rest, ctx):
591
645
  parsed = _parse_update_args(rest)
592
646
  if parsed["all"]:
@@ -704,6 +758,12 @@ def handle_update(rest, ctx):
704
758
  if version_warning:
705
759
  warnings.append(version_warning)
706
760
 
761
+ # The tray companion is a separate artifact on a separate release, so it
762
+ # drifts unless something moves it. Reported rather than silent either way:
763
+ # an install that did not happen is as worth knowing as one that did.
764
+ for tray_warning in _align_tray_companion(ctx, target_version):
765
+ warnings.append(tray_warning)
766
+
707
767
  message = f"Updated cdx-manager to {target_version}"
708
768
  if json_flag:
709
769
  _write_json(ctx, _json_success(
@@ -27,6 +27,7 @@ from ..tray_events import (
27
27
  acknowledge as ack_events,
28
28
  )
29
29
  from ..tray_events import (
30
+ events_path,
30
31
  read_events,
31
32
  write_heartbeat,
32
33
  )
@@ -41,10 +42,13 @@ from ..tray_install import (
41
42
  uninstall,
42
43
  )
43
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
44
48
 
45
49
  TRAY_ACTIONS = (
46
50
  "status", "install", "launch", "uninstall", "autostart", "doctor",
47
- "events", "ack", "heartbeat", "alerts",
51
+ "events", "ack", "heartbeat", "alerts", "plugin", "terminal",
48
52
  )
49
53
 
50
54
  # Codes rather than prose, so a caller can branch on them.
@@ -85,9 +89,23 @@ def handle_tray(rest, ctx):
85
89
  return _tray_heartbeat(args, ctx)
86
90
  if action == "alerts":
87
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)
88
96
  return _tray_install(args, ctx)
89
97
 
90
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
+
91
109
  def _tray_status(args, ctx):
92
110
  parsed = _parse_flag_args(args, {
93
111
  "--json": {"key": "json", "type": "bool", "default": False},
@@ -102,11 +120,21 @@ def _tray_status(args, ctx):
102
120
  force_refresh=parsed["refresh"],
103
121
  cache_only=not parsed["refresh"],
104
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
+ )
105
131
  snapshot = build_snapshot(
106
132
  rows,
107
133
  datetime.now(timezone.utc),
108
134
  ctx["version"],
109
135
  refreshable=not any(row.get("active") for row in rows),
136
+ plugins=cards,
137
+ terminal=terminal_preference(ctx["service"]["base_dir"]),
110
138
  )
111
139
  # Pending alerts ride along with the snapshot rather than costing their own
112
140
  # call. Across WSL every invocation is a `wsl.exe` crossing measured at
@@ -116,6 +144,11 @@ def _tray_status(args, ctx):
116
144
  base_dir = ctx["service"]["base_dir"]
117
145
  snapshot["events"] = read_events(base_dir)
118
146
  snapshot["alerts_enabled"] = alerts_enabled(base_dir)
147
+ # Where the alerts land, so a companion on this machine can watch the file
148
+ # instead of waiting for its next poll. Meaningless across WSL — a Windows
149
+ # process cannot stat a path in the Linux filesystem — and the companion
150
+ # ignores it there rather than CDX pretending otherwise.
151
+ snapshot["spool_path"] = events_path(base_dir)
119
152
  # `--beat` is what claims ownership of alerts, so it is opt-in: a caller
120
153
  # that merely looks at the status must not make `cdx notify` believe a tray
121
154
  # is listening.
@@ -199,10 +232,70 @@ def _tray_uninstall(args, ctx):
199
232
  return 0
200
233
 
201
234
 
235
+ def _confirm_autostart():
236
+ answer = input("Start the CDX tray automatically at login? [Y/n] ")
237
+ return answer.strip().lower() in ("", "y", "yes")
238
+
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
+
202
289
  def _tray_install(args, ctx):
203
290
  parsed = _parse_flag_args(args, {
204
291
  "--json": {"key": "json", "type": "bool", "default": False},
292
+ "--yes": {"key": "yes", "type": "bool", "default": False},
293
+ "--autostart": {"key": "autostart", "type": "bool", "default": False},
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},
205
297
  }, TRAY_USAGE, positionals_key="args", max_positionals=0)
298
+ env = ctx.get("env")
206
299
  target = current_target()
207
300
  if not target:
208
301
  return _refuse(
@@ -222,14 +315,210 @@ def _tray_install(args, ctx):
222
315
  raise CdxError(str(error)) from error
223
316
 
224
317
  message = f"Installed the {state['target']} tray companion for CDX {state['cdx_version']}"
318
+
319
+ # Installing something you asked for should show you the thing. The tray is
320
+ # started here rather than left for a second command, and a failure to start
321
+ # is reported without undoing an install that worked.
322
+ started = False
323
+ try:
324
+ spawn = ctx.get("spawn_detached") or _spawn_detached
325
+ spawn(launch_command(state["executable"]))
326
+ started = True
327
+ except Exception as error: # noqa: BLE001 - the install itself succeeded
328
+ ctx_error = str(error)
329
+ else:
330
+ ctx_error = None
331
+
332
+ # Startup is asked for, never assumed. `req_038` AC1 originally forbade it
333
+ # outright, for a good reason — an install that quietly adds a login item is
334
+ # what people resent about desktop software. Asking keeps the reason and
335
+ # drops the friction; a non-interactive run declines rather than deciding.
336
+ autostart_on = False
337
+ wanted, autostart_reason = _accepted(parsed, ctx, "autostart", "no_autostart", _confirm_autostart)
338
+ if wanted:
339
+ try:
340
+ autostart_enable(state["executable"], env=env, run=ctx.get("spawn_sync"))
341
+ autostart_on = True
342
+ except Exception as error: # noqa: BLE001
343
+ autostart_reason = str(error)
344
+ elif autostart_reason is None:
345
+ autostart_reason = "not enabled; run: cdx tray autostart on"
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
+
225
363
  if parsed["json"]:
226
364
  _write_json(ctx, _json_success(
227
365
  "tray.install", message,
228
366
  target=state["target"], executable=state["executable"],
229
367
  sha256=state["sha256"], applied=True,
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"],
230
372
  ))
231
373
  return 0
232
374
  ctx["out"](f"{message}\n{_dim(state['executable'], ctx['use_color'])}\n")
375
+ ctx["out"](f"{'Started it.' if started else f'It is installed but did not start: {ctx_error}'}\n")
376
+ ctx["out"](
377
+ f"{'It will start at login.' if autostart_on else f'Startup: {autostart_reason}'}\n"
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")
233
522
  return 0
234
523
 
235
524
 
@@ -308,7 +597,12 @@ def _tray_doctor(args, ctx):
308
597
  executable or "run: cdx tray install"),
309
598
  ("executable", "present" if executable and os.path.exists(executable) else "missing",
310
599
  executable or "-"),
311
- ("cdx_version", ctx["version"], (state or {}).get("cdx_version") or "-"),
600
+ # Named rather than left as two numbers to compare: drift is the state
601
+ # that makes someone reinstall by hand, and `cdx update` now fixes it,
602
+ # so the report should say which one you are in.
603
+ ("cdx_version",
604
+ "aligned" if not state or state.get("cdx_version") == ctx["version"] else "drifted",
605
+ f"cdx {ctx['version']}, companion {(state or {}).get('cdx_version') or 'not installed'}"),
312
606
  ("target", (state or {}).get("target") or "-", (state or {}).get("sha256") or "-"),
313
607
  ("running", "yes" if instance.get("pid") else "no", str(instance.get("pid") or "-")),
314
608
  ("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
@@ -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,
@@ -19,10 +19,11 @@ from .session_status import (
19
19
  CODEX_STATUS_CACHE_TTL_SECONDS,
20
20
  STATUS_CACHE_TTL_SECONDS,
21
21
  )
22
+ from .status_view import _format_reset_time
22
23
 
23
24
  SCHEMA_NAME = "cdx.tray.snapshot"
24
25
  SCHEMA_MAJOR = 1
25
- SCHEMA_MINOR = 0
26
+ SCHEMA_MINOR = 2
26
27
 
27
28
  # Below this much remaining capacity a session cannot usefully take work. Shared
28
29
  # with the ranking's own usability threshold so the tray and `cdx next` do not
@@ -96,6 +97,26 @@ def _eligible(row):
96
97
  return row.get("enabled", True) is not False
97
98
 
98
99
 
100
+ def _ago(seconds):
101
+ """How long ago, in the shortest form that stays unambiguous.
102
+
103
+ `None` when nothing was ever reported: "never" is a state the caller already
104
+ names, and inventing an age for it would be worse than saying nothing.
105
+ """
106
+ if seconds is None:
107
+ return None
108
+ seconds = max(0, int(seconds))
109
+ if seconds < 60:
110
+ return "just now"
111
+ minutes = seconds // 60
112
+ if minutes < 60:
113
+ return f"{minutes}m ago"
114
+ hours = minutes // 60
115
+ if hours < 24:
116
+ return f"{hours}h ago"
117
+ return f"{hours // 24}d ago"
118
+
119
+
99
120
  def menu_session(row, now):
100
121
  """One menu line. Everything the tray shows once the user opens it."""
101
122
  freshness, age = session_freshness(row, now)
@@ -107,6 +128,12 @@ def menu_session(row, now):
107
128
  "remaining_5h_pct": row.get("remaining_5h_pct"),
108
129
  "remaining_week_pct": row.get("remaining_week_pct"),
109
130
  "reset_at": row.get("reset_at"),
131
+ # The same wording `cdx status` uses — "in 5h", "passed 3h ago" — rather
132
+ # than a second vocabulary for the same idea. An absolute stamp costs a
133
+ # dozen characters per row and still makes the reader do the subtraction
134
+ # that matters: how long until it comes back.
135
+ "reset_in": _format_reset_time(row.get("reset_at")),
136
+ "updated_ago": _ago(age),
110
137
  "active": bool(row.get("active")),
111
138
  "state": icon_state_for_pct(row.get("available_pct")),
112
139
  "freshness": freshness,
@@ -196,7 +223,7 @@ def tooltip_for(worst):
196
223
  return "CDX · " + " · ".join(parts)
197
224
 
198
225
 
199
- def build_snapshot(rows, now, cdx_version, refreshable=True):
226
+ def build_snapshot(rows, now, cdx_version, refreshable=True, plugins=None, terminal=None):
200
227
  """The whole contract, from the rows `cdx status` already returns.
201
228
 
202
229
  `icon` carries no session name, account, or figure: it is what shows while
@@ -227,6 +254,15 @@ def build_snapshot(rows, now, cdx_version, refreshable=True):
227
254
  "sessions": sessions,
228
255
  "refreshable": bool(refreshable),
229
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,
230
266
  }
231
267
 
232
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)