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/README.md +39 -7
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/changelogs/CHANGELOGS_0_18_6.md +8 -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 +28 -11
- package/src/cli_args.py +3 -2
- package/src/commands/launch.py +56 -5
- package/src/commands/maintenance.py +24 -13
- package/src/commands/tray.py +252 -6
- package/src/session_service.py +25 -1
- package/src/session_status.py +4 -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 +163 -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/update_manager.py +1 -0
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},
|
package/src/commands/launch.py
CHANGED
|
@@ -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(
|
|
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
|
|
|
@@ -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
|
package/src/commands/tray.py
CHANGED
|
@@ -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 =
|
|
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()):
|
|
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
|
-
|
|
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 "-"),
|
package/src/session_service.py
CHANGED
|
@@ -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":
|
|
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
|
|
package/src/session_status.py
CHANGED
|
@@ -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(
|
|
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 {},
|
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
|
|