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/README.md +42 -6
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_4.md +78 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/checksums/release-archives.json +22 -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 +60 -0
- package/src/commands/tray.py +296 -2
- package/src/session_service.py +23 -1
- package/src/tray_contract.py +38 -2
- package/src/tray_defaults.py +48 -0
- package/src/tray_events.py +9 -1
- package/src/tray_install.py +125 -4
- 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
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
|
-
|
|
100
|
-
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
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(
|
|
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
|
-
|
|
116
|
+
if metadata_lookup is None:
|
|
117
|
+
from importlib.metadata import version as metadata_lookup
|
|
111
118
|
|
|
112
|
-
return
|
|
113
|
-
except Exception: # noqa: BLE001 -
|
|
114
|
-
|
|
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(
|
package/src/commands/tray.py
CHANGED
|
@@ -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
|
-
|
|
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"),
|
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
|
@@ -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 =
|
|
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)
|