cdx-manager 0.18.4 → 0.18.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
@@ -43,7 +43,7 @@ RESUME_USAGE = "Usage: cdx resume <name> [--json]"
43
43
  CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
44
44
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
45
45
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
46
- RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--failover] [--refresh] --json"
46
+ RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--failover] [--refresh] --json"
47
47
  RUN_JSON_REQUIRED = "cdx run: --json is required."
48
48
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
49
49
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -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
@@ -585,6 +585,7 @@ def _parse_run_effort(reasoning_effort, power):
585
585
  def _parse_run_args(args):
586
586
  parsed = _parse_run_flag_args(args, {
587
587
  "--cwd": {"key": "cwd", "type": "str", "default": None},
588
+ "--dir": {"key": "cwd", "type": "str", "default": None},
588
589
  "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
589
590
  "--prompt": {"key": "prompt", "type": "str", "default": None},
590
591
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": _parse_run_provider},
@@ -28,6 +28,54 @@ from ..errors import CdxError
28
28
  from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
29
29
 
30
30
 
31
+ def _parse_launch_args(args):
32
+ """Return the per-launch directory and ordinary launch flags."""
33
+ directory = None
34
+ cleaned = []
35
+ index = 0
36
+ while index < len(args):
37
+ arg = args[index]
38
+ if arg == "--dir":
39
+ if directory is not None or index + 1 >= len(args):
40
+ raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
41
+ directory = args[index + 1]
42
+ index += 2
43
+ continue
44
+ if arg.startswith("--dir="):
45
+ if directory is not None or not arg.split("=", 1)[1]:
46
+ raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
47
+ directory = arg.split("=", 1)[1]
48
+ index += 1
49
+ continue
50
+ cleaned.append(arg)
51
+ index += 1
52
+ return directory, cleaned
53
+
54
+
55
+ def _launch_directory(session, ctx, explicit, json_flag):
56
+ cwd = os.path.realpath(explicit) if explicit else (ctx.get("cwd") or os.getcwd())
57
+ if explicit and not os.path.isdir(cwd):
58
+ raise CdxError(f"Invalid directory: {explicit or cwd}")
59
+ if explicit or json_flag or not ctx["stdin_is_tty"] or cwd not in {os.path.realpath(os.path.expanduser("~")), os.path.realpath(os.path.sep)}:
60
+ return cwd
61
+ recent = []
62
+ for entry in ctx["service"]["get_launch_history"](session["name"], limit=20):
63
+ path = entry.get("cwd")
64
+ if path and os.path.isdir(path) and path not in recent:
65
+ recent.append(path)
66
+ choices = recent + [cwd]
67
+ if len(choices) == 1:
68
+ return cwd
69
+ ctx["out"]("Choose launch directory:\n" + "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices)))
70
+ answer = (ctx["options"].get("input") or input)(f"Directory [1-{len(choices)}] (default {len(choices)}): ").strip()
71
+ if not answer:
72
+ return cwd
73
+ try:
74
+ return choices[int(answer) - 1]
75
+ except (ValueError, IndexError):
76
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
77
+
78
+
31
79
  def _format_resume_capability(capability, use_color=False):
32
80
  name = capability["session"]
33
81
  provider = capability["provider"]
@@ -74,12 +122,13 @@ def handle_can_resume(rest, ctx):
74
122
  return 0
75
123
 
76
124
  def handle_resume(rest, ctx):
125
+ directory, rest = _parse_launch_args(rest)
77
126
  json_flag, args = _parse_json_flag(rest)
78
127
  if len(args) != 1:
79
128
  raise CdxError(RESUME_USAGE)
80
- return handle_launch(args[0], ctx, resume=True, force_json=json_flag)
129
+ return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory)
81
130
 
82
- def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None):
131
+ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
83
132
  json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
84
133
  if force_json is not None:
85
134
  json_flag = force_json
@@ -91,6 +140,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
91
140
  raise CdxError(
92
141
  f"Provider {session['provider']} does not support native resume through cdx."
93
142
  )
143
+ cwd = _launch_directory(session, ctx, directory, json_flag)
144
+ if not json_flag and not ctx["stdin_is_tty"]:
145
+ ctx["out"](f"Using directory: {cwd}\n")
94
146
  _ensure_session_authentication(
95
147
  session,
96
148
  ctx["service"],
@@ -127,13 +179,12 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
127
179
  # a real turn — so it gets no instruction it does not need.
128
180
  notice += " — approve them once in Codex to enable"
129
181
  ctx["out"](f"{_info(notice, ctx['use_color'])}\n")
130
- cwd = ctx.get("cwd") or os.getcwd()
131
182
  runtime_run_id = None
132
183
 
133
184
  def runtime_lifecycle(event, info):
134
185
  nonlocal runtime_run_id
135
186
  if event == "started":
136
- runtime = ctx["service"]["start_session_runtime"](session["name"], info)
187
+ runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd})
137
188
  runtime_run_id = runtime.get("runId")
138
189
  elif event == "finished" and runtime_run_id:
139
190
  ctx["service"]["finish_session_runtime"](
@@ -176,7 +227,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
176
227
  **run_info,
177
228
  })
178
229
  if json_flag:
179
- extra = {"session": ctx["service"]["get_session"](session["name"])}
230
+ extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
180
231
  if capability:
181
232
  extra["resume"] = capability
182
233
  _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
@@ -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
 
@@ -765,10 +774,12 @@ def handle_update(rest, ctx):
765
774
  current_version=current_version,
766
775
  target_version=target_version,
767
776
  mode=plan["mode"],
777
+ prefix=plan["prefix"],
768
778
  steps=results,
769
779
  ))
770
780
  return 0
771
781
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
782
+ ctx["out"](f"Install prefix: {plan['prefix']}\n")
772
783
  for warning in warnings:
773
784
  ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
774
785
  return 0
@@ -5,12 +5,14 @@ and `autostart` act on recorded state and say plainly when there is none, and
5
5
  `doctor` reads all of it without changing any of it.
6
6
  """
7
7
  import os
8
+ import shutil
8
9
  import subprocess
9
10
  from datetime import datetime, timezone
10
11
 
11
12
  from ..cli_args import TRAY_USAGE, _parse_flag_args
12
13
  from ..cli_helpers import _json_success, _write_json
13
14
  from ..cli_render import _dim, _pad_table, _style, _warn
15
+ from ..config import get_cdx_home
14
16
  from ..errors import CdxError
15
17
  from ..tray_alerts import alerts_enabled, set_alerts
16
18
  from ..tray_autostart import disable as autostart_disable
@@ -42,10 +44,13 @@ from ..tray_install import (
42
44
  uninstall,
43
45
  )
44
46
  from ..tray_instance import companion_instance
47
+ from ..tray_plugins import ADAPTERS, collect_cards, enabled_plugins, set_plugin_enabled
48
+ from ..tray_terminal import REFUSAL as TERMINAL_REFUSAL
49
+ from ..tray_terminal import clear_terminal, set_terminal, terminal_preference
45
50
 
46
51
  TRAY_ACTIONS = (
47
52
  "status", "install", "launch", "uninstall", "autostart", "doctor",
48
- "events", "ack", "heartbeat", "alerts",
53
+ "events", "ack", "heartbeat", "alerts", "plugin", "terminal",
49
54
  )
50
55
 
51
56
  # Codes rather than prose, so a caller can branch on them.
@@ -86,9 +91,23 @@ def handle_tray(rest, ctx):
86
91
  return _tray_heartbeat(args, ctx)
87
92
  if action == "alerts":
88
93
  return _tray_alerts(args, ctx)
94
+ if action == "plugin":
95
+ return _tray_plugin(args, ctx)
96
+ if action == "terminal":
97
+ return _tray_terminal(args, ctx)
89
98
  return _tray_install(args, ctx)
90
99
 
91
100
 
101
+ def _plugin_cache(ctx):
102
+ """The per-process card cache.
103
+
104
+ `cdx tray status` is a fresh process per poll, so this only helps within one
105
+ invocation today. It exists as the seam the TTL is honoured through, and the
106
+ place a longer-lived cache would go without every adapter learning about it.
107
+ """
108
+ return ctx.setdefault("plugin_cache", {})
109
+
110
+
92
111
  def _tray_status(args, ctx):
93
112
  parsed = _parse_flag_args(args, {
94
113
  "--json": {"key": "json", "type": "bool", "default": False},
@@ -103,11 +122,22 @@ def _tray_status(args, ctx):
103
122
  force_refresh=parsed["refresh"],
104
123
  cache_only=not parsed["refresh"],
105
124
  )
125
+ # Enabled integrations contribute a card each. A manual refresh passes no
126
+ # cache, so it is what actually re-asks them; a poll reuses a card for its
127
+ # TTL and does not make an integration pay the tray's rate.
128
+ cards = collect_cards(
129
+ ctx["service"]["base_dir"],
130
+ env=ctx.get("env"),
131
+ cache=None if parsed["refresh"] else _plugin_cache(ctx),
132
+ directories=[row.get("cwd") for row in rows if row.get("active") and row.get("cwd")],
133
+ )
106
134
  snapshot = build_snapshot(
107
135
  rows,
108
136
  datetime.now(timezone.utc),
109
137
  ctx["version"],
110
138
  refreshable=not any(row.get("active") for row in rows),
139
+ plugins=cards,
140
+ terminal=terminal_preference(ctx["service"]["base_dir"]),
111
141
  )
112
142
  # Pending alerts ride along with the snapshot rather than costing their own
113
143
  # call. Across WSL every invocation is a `wsl.exe` crossing measured at
@@ -210,11 +240,63 @@ def _confirm_autostart():
210
240
  return answer.strip().lower() in ("", "y", "yes")
211
241
 
212
242
 
243
+ def _confirm_alerts():
244
+ answer = input("Notify you when an agent finishes a turn or waits for you? [Y/n] ")
245
+ return answer.strip().lower() in ("", "y", "yes")
246
+
247
+
248
+ def _accepted(parsed, ctx, on_flag, off_flag, confirm):
249
+ """One consent, resolved the same way for each question.
250
+
251
+ Two rules make it predictable. An explicit flag always wins, so a script
252
+ gets what it asked for and never a prompt. And in the absence of one, only
253
+ an interactive human-readable run asks — a piped or `--json` invocation
254
+ declines rather than deciding on someone's behalf.
255
+ """
256
+ if parsed[off_flag]:
257
+ return False, f"skipped with --{off_flag.replace('_', '-')}"
258
+ if parsed[on_flag] or parsed["yes"]:
259
+ return True, None
260
+ if ctx["stdin_is_tty"] and not parsed["json"] and confirm():
261
+ return True, None
262
+ return False, None
263
+
264
+
265
+ def _enable_alerts_everywhere(ctx):
266
+ """Turn alerts on for every session that can have them, and for future ones.
267
+
268
+ The two halves are one promise: a user who accepted alerts means the tray
269
+ tells them when an agent needs them, not that it does so for the sessions
270
+ that happened to exist at the moment they answered.
271
+
272
+ Providers with no hook mechanism are left alone rather than written to with
273
+ a setting they will ignore.
274
+ """
275
+ from ..agent_notify import supports_agent_alerts
276
+ from ..tray_defaults import set_alerts_default
277
+
278
+ service = ctx["service"]
279
+ set_alerts_default(service["base_dir"], True)
280
+ enabled, codex = [], False
281
+ for session in service["list_sessions"]():
282
+ if not supports_agent_alerts(session["provider"]):
283
+ continue
284
+ if (session.get("launch") or {}).get("notify") is True:
285
+ continue
286
+ service["set_launch_settings"](session["name"], {"notify": True})
287
+ enabled.append(session["name"])
288
+ codex = codex or session["provider"] == "codex"
289
+ return {"sessions": enabled, "codex_trust_required": codex}
290
+
291
+
213
292
  def _tray_install(args, ctx):
214
293
  parsed = _parse_flag_args(args, {
215
294
  "--json": {"key": "json", "type": "bool", "default": False},
216
295
  "--yes": {"key": "yes", "type": "bool", "default": False},
296
+ "--autostart": {"key": "autostart", "type": "bool", "default": False},
217
297
  "--no-autostart": {"key": "no_autostart", "type": "bool", "default": False},
298
+ "--alerts": {"key": "alerts", "type": "bool", "default": False},
299
+ "--no-alerts": {"key": "no_alerts", "type": "bool", "default": False},
218
300
  }, TRAY_USAGE, positionals_key="args", max_positionals=0)
219
301
  env = ctx.get("env")
220
302
  target = current_target()
@@ -255,24 +337,41 @@ def _tray_install(args, ctx):
255
337
  # what people resent about desktop software. Asking keeps the reason and
256
338
  # drops the friction; a non-interactive run declines rather than deciding.
257
339
  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()):
340
+ wanted, autostart_reason = _accepted(parsed, ctx, "autostart", "no_autostart", _confirm_autostart)
341
+ if wanted:
262
342
  try:
263
343
  autostart_enable(state["executable"], env=env, run=ctx.get("spawn_sync"))
264
344
  autostart_on = True
265
345
  except Exception as error: # noqa: BLE001
266
346
  autostart_reason = str(error)
267
- else:
347
+ elif autostart_reason is None:
268
348
  autostart_reason = "not enabled; run: cdx tray autostart on"
269
349
 
350
+ # The second question, asked separately because it is a different decision
351
+ # with a different cost: one adds a login item, the other writes hooks into
352
+ # each provider's own configuration.
353
+ alerts_on = False
354
+ alerts_reason = None
355
+ alerts_result = {"sessions": [], "codex_trust_required": False}
356
+ wanted, alerts_reason = _accepted(parsed, ctx, "alerts", "no_alerts", _confirm_alerts)
357
+ if wanted:
358
+ try:
359
+ alerts_result = _enable_alerts_everywhere(ctx)
360
+ alerts_on = True
361
+ except Exception as error: # noqa: BLE001 - the install itself succeeded
362
+ alerts_reason = str(error)
363
+ elif alerts_reason is None:
364
+ alerts_reason = "not enabled; run: cdx set --all --notify on"
365
+
270
366
  if parsed["json"]:
271
367
  _write_json(ctx, _json_success(
272
368
  "tray.install", message,
273
369
  target=state["target"], executable=state["executable"],
274
370
  sha256=state["sha256"], applied=True,
275
371
  started=started, autostart=autostart_on,
372
+ alerts=alerts_on,
373
+ alerts_sessions=alerts_result["sessions"],
374
+ codex_trust_required=alerts_result["codex_trust_required"],
276
375
  ))
277
376
  return 0
278
377
  ctx["out"](f"{message}\n{_dim(state['executable'], ctx['use_color'])}\n")
@@ -280,6 +379,149 @@ def _tray_install(args, ctx):
280
379
  ctx["out"](
281
380
  f"{'It will start at login.' if autostart_on else f'Startup: {autostart_reason}'}\n"
282
381
  )
382
+ if alerts_on:
383
+ count = len(alerts_result["sessions"])
384
+ covered = f"{count} session(s)" if count else "no existing session"
385
+ ctx["out"](
386
+ f"Agent alerts: on for {covered}, and for new ones — "
387
+ "hooks install at each session's next launch.\n"
388
+ )
389
+ if alerts_result["codex_trust_required"]:
390
+ # Said here rather than discovered later: Codex puts an approval
391
+ # prompt in front of the user at the next launch, and cdx will not
392
+ # write that trust on their behalf.
393
+ ctx["out"]("Codex will ask you once to approve the cdx hook plugin.\n")
394
+ else:
395
+ ctx["out"](f"Agent alerts: {alerts_reason}\n")
396
+ return 0
397
+
398
+
399
+ PLUGIN_UNKNOWN = "tray_plugin_unknown"
400
+ TERMINAL_INVALID = "tray_terminal_invalid"
401
+
402
+
403
+ def _tray_terminal(args, ctx):
404
+ """Choose the terminal a session row opens, or go back to the default.
405
+
406
+ A refusal is a refusal, not a silent normalisation: a value that cannot be
407
+ stored says why, and nothing is written. Storing a sanitized version of what
408
+ someone typed would make the rule invisible exactly where it matters.
409
+ """
410
+ parsed = _parse_flag_args(args, {
411
+ "--json": {"key": "json", "type": "bool", "default": False},
412
+ }, TRAY_USAGE, positionals_key="args", max_positionals=2)
413
+ positional = parsed["args"] or ["status"]
414
+ mode = positional[0]
415
+ base_dir = ctx["service"]["base_dir"]
416
+
417
+ if mode == "clear":
418
+ clear_terminal(base_dir)
419
+ elif mode == "set":
420
+ if len(positional) < 2:
421
+ raise CdxError(TRAY_USAGE)
422
+ try:
423
+ set_terminal(base_dir, positional[1])
424
+ except ValueError:
425
+ return _refuse(ctx, parsed["json"], "tray.terminal", TERMINAL_INVALID, TERMINAL_REFUSAL)
426
+ elif mode != "status":
427
+ raise CdxError(TRAY_USAGE)
428
+
429
+ chosen = terminal_preference(base_dir)
430
+ message = (
431
+ f"Tray session rows open {chosen}." if chosen
432
+ else "Tray session rows open this platform's default terminal."
433
+ )
434
+ if parsed["json"]:
435
+ _write_json(ctx, _json_success(
436
+ "tray.terminal", message, terminal=chosen, applied=mode in ("set", "clear"),
437
+ ))
438
+ return 0
439
+ ctx["out"](f"{message}\n")
440
+ if chosen:
441
+ # Said once, at the moment it is chosen: the companion falls back
442
+ # silently, and a user whose terminal never opens should know that is
443
+ # the designed behaviour rather than a broken setting.
444
+ ctx["out"](f"{_dim('If it is not installed, the platform default opens instead.', ctx['use_color'])}\n")
445
+ return 0
446
+
447
+
448
+ def _tray_plugin(args, ctx):
449
+ """Turn an integration on or off, and say what is on.
450
+
451
+ Explicit in both directions and reversible, because an integration runs
452
+ another tool's command on the user's behalf: nothing here has a default that
453
+ turns something on, and `disable` is not a hidden setting.
454
+ """
455
+ parsed = _parse_flag_args(args, {
456
+ "--json": {"key": "json", "type": "bool", "default": False},
457
+ }, TRAY_USAGE, positionals_key="args", max_positionals=2)
458
+ positional = parsed["args"] or ["status"]
459
+ mode = positional[0]
460
+ if mode not in ("enable", "disable", "status", "run"):
461
+ raise CdxError(TRAY_USAGE)
462
+ base_dir = ctx["service"]["base_dir"]
463
+
464
+ if mode == "run":
465
+ return _tray_plugin_run(positional[1:], parsed, ctx)
466
+
467
+ if mode in ("enable", "disable"):
468
+ if len(positional) < 2:
469
+ raise CdxError(TRAY_USAGE)
470
+ name = positional[1]
471
+ if name not in ADAPTERS:
472
+ return _refuse(
473
+ ctx, parsed["json"], "tray.plugin", PLUGIN_UNKNOWN,
474
+ f"No tray integration is called {name}. Known: {', '.join(sorted(ADAPTERS))}.",
475
+ )
476
+ set_plugin_enabled(base_dir, name, mode == "enable")
477
+
478
+ enabled = enabled_plugins(base_dir)
479
+ available = _available_plugins(ctx, enabled)
480
+ message = (
481
+ f"Tray integrations enabled: {', '.join(enabled)}" if enabled
482
+ else "No tray integration is enabled."
483
+ )
484
+ if parsed["json"]:
485
+ _write_json(ctx, _json_success(
486
+ "tray.plugin", message, enabled=enabled, known=sorted(ADAPTERS),
487
+ activatable=available, applied=mode in ("enable", "disable"),
488
+ ))
489
+ return 0
490
+ ctx["out"](f"{message}\n")
491
+ for name in available:
492
+ # Said once, as an offer rather than a nudge repeated on every command:
493
+ # the tool is here, the card is not on, and this is how to turn it on.
494
+ ctx["out"](f"{_dim(f'{name} is installed but not enabled. Run: cdx tray plugin enable {name}', ctx['use_color'])}\n")
495
+ return 0
496
+
497
+
498
+ def _available_plugins(ctx, enabled):
499
+ """Integrations whose tool is installed and whose card is not enabled."""
500
+ from ..logics_view import resolve_logics_manager
501
+
502
+ if "logics" in enabled or not resolve_logics_manager(ctx.get("env") or {}):
503
+ return []
504
+ return ["logics"]
505
+
506
+
507
+ def _tray_plugin_run(rest, parsed, ctx):
508
+ """Perform one card action, named by its id.
509
+
510
+ The tray sends an id back rather than a command, and this is where that
511
+ matters: the id is matched against what CDX knows how to do, and anything
512
+ else is refused. There is no path from a card to a shell.
513
+ """
514
+ from ..tray_plugin_actions import perform_action
515
+
516
+ if not rest:
517
+ raise CdxError(TRAY_USAGE)
518
+ result = perform_action(rest[0], ctx)
519
+ if not result["ok"]:
520
+ return _refuse(ctx, parsed["json"], "tray.plugin.run", result["code"], result["message"])
521
+ if parsed["json"]:
522
+ _write_json(ctx, _json_success("tray.plugin.run", result["message"], applied=True))
523
+ return 0
524
+ ctx["out"](f"{result['message']}\n")
283
525
  return 0
284
526
 
285
527
 
@@ -352,6 +594,8 @@ def _tray_doctor(args, ctx):
352
594
  instance = companion_instance(env=env)
353
595
  desktop = desktop_capability(env=env)
354
596
  toasts = toast_capability(env=env)
597
+ companion_cdx = (env or {}).get("CDX_TRAY_CDX") or shutil.which("cdx", path=(env or {}).get("PATH")) or "cdx"
598
+ hook_store = get_cdx_home(env)
355
599
 
356
600
  checks = [
357
601
  ("companion", "installed" if state else ("override" if executable else "absent"),
@@ -369,6 +613,8 @@ def _tray_doctor(args, ctx):
369
613
  ("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
370
614
  ("alerts", "on" if alerts_enabled(base_dir) else "muted",
371
615
  "cdx tray alerts on|off"),
616
+ ("companion_cdx", "override" if (env or {}).get("CDX_TRAY_CDX") else "path", companion_cdx),
617
+ ("hook_store", "aligned" if os.path.realpath(hook_store) == os.path.realpath(base_dir) else "mismatched", hook_store),
372
618
  ("update", "interrupted" if interrupted_update(base_dir) else "clean",
373
619
  "a staged companion was never promoted; run: cdx tray install"
374
620
  if interrupted_update(base_dir) else "-"),
@@ -614,6 +614,29 @@ 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
+ launch["notify_preview"] = True
633
+ except OSError:
634
+ # An unreadable default is no default. Creating the session matters
635
+ # more than inheriting a preference.
636
+ pass
637
+ return launch
638
+
639
+
617
640
  def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
618
641
  _validate_new_session_name(name)
619
642
  normalized_provider = _normalize_provider(provider)
@@ -639,7 +662,7 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
639
662
  "lastLaunchedAt": None,
640
663
  "lastStatusAt": None,
641
664
  "lastStatus": None,
642
- "launch": dict(DEFAULT_LAUNCH_SETTINGS),
665
+ "launch": _launch_defaults_for(base_dir, normalized_provider),
643
666
  "auth": {
644
667
  "status": "unknown",
645
668
  "lastCheckedAt": None,
@@ -717,6 +740,7 @@ def start_session_runtime(store, name, payload=None):
717
740
  "pid": payload.get("pid") or os.getpid(),
718
741
  "command": payload.get("command"),
719
742
  "label": payload.get("label"),
743
+ "cwd": payload.get("cwd"),
720
744
  "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
721
745
  }
722
746
 
@@ -268,6 +268,7 @@ def _status_row_from_session(store, s):
268
268
  status = s.get("lastStatus")
269
269
  enabled = s.get("enabled", True) is not False
270
270
  row_status = status if enabled else None
271
+ runtime = _session_runtime(store, s["name"])
271
272
  return {
272
273
  "session_name": s["name"],
273
274
  "label": s.get("label"),
@@ -279,7 +280,8 @@ def _status_row_from_session(store, s):
279
280
  # ignored `--priority` entirely.
280
281
  "priority": _row_priority(s),
281
282
  "reasoning_effort": _row_reasoning_effort(s),
282
- "active": bool(_session_runtime(store, s["name"])) if enabled else False,
283
+ "active": bool(runtime) if enabled else False,
284
+ "cwd": runtime.get("cwd") if runtime else None,
283
285
  "status": "enabled" if enabled else "disabled",
284
286
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
285
287
  "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
@@ -313,6 +315,7 @@ def format_list_rows(store):
313
315
  "provider": s["provider"] if has_multiple else None,
314
316
  "enabled": s.get("enabled", True) is not False,
315
317
  "active": bool(_session_runtime(store, s["name"])) if s.get("enabled", True) is not False else False,
318
+ "cwd": (_session_runtime(store, s["name"]) or {}).get("cwd"),
316
319
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
317
320
  "status": s.get("lastStatus"),
318
321
  "launch": s.get("launch") or {},
@@ -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