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.
- package/README.md +37 -6
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +192 -12
- package/src/cli.py +24 -9
- package/src/cli_args.py +1 -1
- package/src/commands/maintenance.py +22 -13
- package/src/commands/tray.py +245 -6
- package/src/session_service.py +23 -1
- package/src/tray_contract.py +11 -2
- package/src/tray_defaults.py +48 -0
- package/src/tray_events.py +9 -1
- package/src/tray_install.py +101 -8
- package/src/tray_logics.py +141 -0
- package/src/tray_plugin_actions.py +62 -0
- package/src/tray_plugins.py +163 -0
- package/src/tray_restart.py +152 -0
- package/src/tray_terminal.py +80 -0
|
@@ -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(
|
|
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
|
-
#
|
|
622
|
-
#
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
"
|
|
628
|
-
"
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
package/src/commands/tray.py
CHANGED
|
@@ -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 =
|
|
259
|
-
if
|
|
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
|
-
|
|
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
|
|
package/src/session_service.py
CHANGED
|
@@ -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":
|
|
664
|
+
"launch": _launch_defaults_for(base_dir, normalized_provider),
|
|
643
665
|
"auth": {
|
|
644
666
|
"status": "unknown",
|
|
645
667
|
"lastCheckedAt": None,
|
package/src/tray_contract.py
CHANGED
|
@@ -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 =
|
|
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)
|
package/src/tray_events.py
CHANGED
|
@@ -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"
|