cdx-manager 0.15.0 → 0.16.0
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 +41 -6
- package/changelogs/CHANGELOGS_0_15_0.md +9 -4
- package/changelogs/CHANGELOGS_0_15_1.md +85 -0
- package/changelogs/CHANGELOGS_0_16_0.md +97 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +324 -0
- package/src/cli.py +4 -6
- package/src/cli_args.py +4 -2
- package/src/cli_commands.py +1 -1
- package/src/cli_helpers.py +5 -2
- package/src/commands/auth.py +35 -52
- package/src/commands/launch.py +20 -1
- package/src/notify.py +125 -149
- package/src/provider_runtime.py +5 -0
- package/src/run_failover.py +17 -1
- package/src/run_registry.py +41 -3
- package/src/session_service.py +13 -1
- package/src/session_status.py +1 -0
- package/src/status_source.py +14 -0
package/src/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
import sys
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
|
|
8
|
+
from .agent_notify import handle_notify
|
|
8
9
|
from .cli_commands import (
|
|
9
10
|
API_SCHEMA_VERSION,
|
|
10
11
|
STATUS_USAGE,
|
|
@@ -34,7 +35,7 @@ from .cli_commands import (
|
|
|
34
35
|
handle_logout,
|
|
35
36
|
handle_memory,
|
|
36
37
|
handle_next,
|
|
37
|
-
|
|
38
|
+
handle_ready,
|
|
38
39
|
handle_remove,
|
|
39
40
|
handle_rename,
|
|
40
41
|
handle_repair,
|
|
@@ -258,8 +259,7 @@ def _print_help(use_color=False):
|
|
|
258
259
|
f" {_style('cdx view [--json]', '36', use_color)}",
|
|
259
260
|
f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
260
261
|
f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
|
|
261
|
-
f" {_style('cdx notify
|
|
262
|
-
f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
|
|
262
|
+
f" {_style('cdx notify', '36', use_color)} # hook target, called by the provider",
|
|
263
263
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
264
264
|
f" {_style('cdx --help', '36', use_color)}",
|
|
265
265
|
f" {_style('cdx --version', '36', use_color)}",
|
|
@@ -576,9 +576,7 @@ def main(argv, options=None):
|
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
if resolved_command == "ready":
|
|
579
|
-
|
|
580
|
-
raise CdxError("Usage: cdx ready [--refresh] [--json]")
|
|
581
|
-
return handle_notify(["--next-ready", "--schedule", *rest], ctx)
|
|
579
|
+
return handle_ready(rest, ctx)
|
|
582
580
|
|
|
583
581
|
if resolved_command in ("power", "perm", "fast", "model"):
|
|
584
582
|
return handle_launch_setting_alias(resolved_command, rest, ctx)
|
package/src/cli_args.py
CHANGED
|
@@ -248,6 +248,7 @@ def _parse_set_args(args):
|
|
|
248
248
|
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
249
249
|
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
250
250
|
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
|
+
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
252
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
252
253
|
"--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
|
|
253
254
|
"--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
|
|
@@ -269,7 +270,7 @@ def _parse_set_args(args):
|
|
|
269
270
|
raise CdxError(SET_USAGE)
|
|
270
271
|
settings = {
|
|
271
272
|
key: parsed[key]
|
|
272
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
273
|
+
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
273
274
|
if parsed[key] is not None
|
|
274
275
|
}
|
|
275
276
|
if not settings:
|
|
@@ -291,6 +292,7 @@ def _parse_unset_args(args):
|
|
|
291
292
|
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
292
293
|
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
293
294
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
295
|
+
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
294
296
|
"--model": {"key": "model", "type": "bool", "default": False},
|
|
295
297
|
"--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
|
|
296
298
|
"--budget": {"key": "budget", "type": "bool", "default": False},
|
|
@@ -312,7 +314,7 @@ def _parse_unset_args(args):
|
|
|
312
314
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
313
315
|
raise CdxError(UNSET_USAGE)
|
|
314
316
|
_UNSET_KEYS = (
|
|
315
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics",
|
|
317
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify",
|
|
316
318
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
317
319
|
)
|
|
318
320
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
package/src/cli_commands.py
CHANGED
package/src/cli_helpers.py
CHANGED
|
@@ -486,6 +486,7 @@ def _format_launch_config(session, use_color=False):
|
|
|
486
486
|
("fast", "Fast"),
|
|
487
487
|
("rtk", "RTK"),
|
|
488
488
|
("logics", "Logics"),
|
|
489
|
+
("notify", "Notify"),
|
|
489
490
|
("model", "Model"),
|
|
490
491
|
("fallback_model", "Fallback"),
|
|
491
492
|
("budget", "Budget"),
|
|
@@ -508,17 +509,19 @@ def _format_launch_config(session, use_color=False):
|
|
|
508
509
|
def _format_launch_settings_hint(name="<name>"):
|
|
509
510
|
return (
|
|
510
511
|
f"Set a value: cdx set {name} --power medium --permission auto "
|
|
511
|
-
"--fast on --rtk on --logics on --model MODEL --priority 80"
|
|
512
|
+
"--fast on --rtk on --logics on --notify on --model MODEL --priority 80"
|
|
512
513
|
)
|
|
513
514
|
|
|
514
515
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
515
|
-
if key in ("fast", "rtk", "logics"):
|
|
516
|
+
if key in ("fast", "rtk", "logics", "notify"):
|
|
516
517
|
if launch.get(key) is True:
|
|
517
518
|
return _style("on", "32", use_color)
|
|
518
519
|
if launch.get(key) is False:
|
|
519
520
|
return _style("off", "2", use_color)
|
|
520
521
|
if key == "logics":
|
|
521
522
|
return _dim("auto", use_color)
|
|
523
|
+
if key == "notify":
|
|
524
|
+
return _style("off", "2", use_color)
|
|
522
525
|
return _dim("default", use_color)
|
|
523
526
|
value = launch.get(key)
|
|
524
527
|
if value is None or value == "":
|
package/src/commands/auth.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Authentication domain commands: login, logout, reset,
|
|
1
|
+
"""Authentication domain commands: login, logout, reset, ready.
|
|
2
2
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
@@ -20,13 +20,11 @@ from ..codex_usage import consume_codex_rate_limit_reset_credit
|
|
|
20
20
|
from ..config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
21
21
|
from ..errors import CdxError
|
|
22
22
|
from ..notify import (
|
|
23
|
-
format_notify_event,
|
|
24
23
|
format_scheduled_notification,
|
|
25
|
-
|
|
24
|
+
parse_ready_args,
|
|
26
25
|
resolve_notify_event,
|
|
27
26
|
schedule_notification_event,
|
|
28
27
|
send_desktop_notification,
|
|
29
|
-
wait_for_notification_event,
|
|
30
28
|
)
|
|
31
29
|
from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
|
|
32
30
|
|
|
@@ -35,61 +33,46 @@ def _confirm_reset(name):
|
|
|
35
33
|
answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
|
|
36
34
|
return answer.strip().lower() in ("y", "yes")
|
|
37
35
|
|
|
38
|
-
def
|
|
39
|
-
|
|
36
|
+
def handle_ready(rest, ctx):
|
|
37
|
+
"""`cdx ready`: schedule an OS notification for the next session to come back.
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
This is all that remains of the quota-reset flow: `cdx notify` now means
|
|
40
|
+
agent notifications, and the wait-and-poll surface that used to sit here was
|
|
41
|
+
unreachable once `cdx ready` became the only entry point.
|
|
42
|
+
"""
|
|
43
|
+
parsed = parse_ready_args(rest)
|
|
44
|
+
event = resolve_notify_event(
|
|
45
|
+
ctx["service"]["get_status_rows"](
|
|
46
|
+
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
47
|
+
force_refresh=parsed["refresh"],
|
|
48
|
+
),
|
|
49
|
+
parsed,
|
|
50
|
+
(ctx["options"].get("now") or time.time)(),
|
|
51
|
+
)
|
|
52
|
+
if event["ready"]:
|
|
42
53
|
send_desktop_notification(
|
|
43
|
-
title,
|
|
44
|
-
|
|
45
|
-
spawn_sync=ctx.get("spawn_sync"),
|
|
46
|
-
env=ctx.get("env"),
|
|
54
|
+
event["title"], event["message"],
|
|
55
|
+
spawn_sync=ctx.get("spawn_sync"), env=ctx.get("env"),
|
|
47
56
|
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
schedule = {
|
|
58
|
+
"scheduled": False,
|
|
59
|
+
"backend": "immediate",
|
|
60
|
+
"message": event["message"],
|
|
61
|
+
"target_timestamp": event.get("target_timestamp"),
|
|
62
|
+
}
|
|
63
|
+
else:
|
|
64
|
+
schedule = schedule_notification_event(
|
|
65
|
+
ctx["service"]["base_dir"],
|
|
55
66
|
parsed,
|
|
56
|
-
|
|
67
|
+
event,
|
|
68
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
69
|
+
env=ctx.get("env"),
|
|
70
|
+
now_fn=ctx["options"].get("now"),
|
|
57
71
|
)
|
|
58
|
-
if event["ready"]:
|
|
59
|
-
notifier(event["title"], event["message"])
|
|
60
|
-
schedule = {
|
|
61
|
-
"scheduled": False,
|
|
62
|
-
"backend": "immediate",
|
|
63
|
-
"message": event["message"],
|
|
64
|
-
"target_timestamp": event.get("target_timestamp"),
|
|
65
|
-
}
|
|
66
|
-
else:
|
|
67
|
-
schedule = schedule_notification_event(
|
|
68
|
-
ctx["service"]["base_dir"],
|
|
69
|
-
parsed,
|
|
70
|
-
event,
|
|
71
|
-
spawn_sync=ctx.get("spawn_sync"),
|
|
72
|
-
env=ctx.get("env"),
|
|
73
|
-
now_fn=ctx["options"].get("now"),
|
|
74
|
-
)
|
|
75
|
-
if parsed["json"]:
|
|
76
|
-
_write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
|
|
77
|
-
else:
|
|
78
|
-
ctx["out"](f"{format_scheduled_notification(schedule)}\n")
|
|
79
|
-
return 0
|
|
80
|
-
|
|
81
|
-
event = wait_for_notification_event(
|
|
82
|
-
ctx["service"],
|
|
83
|
-
parsed,
|
|
84
|
-
notifier=notifier,
|
|
85
|
-
sleep_fn=ctx["options"].get("sleep"),
|
|
86
|
-
now_fn=ctx["options"].get("now"),
|
|
87
|
-
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
88
|
-
)
|
|
89
72
|
if parsed["json"]:
|
|
90
|
-
_write_json(ctx, _json_success("notify", "
|
|
73
|
+
_write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
|
|
91
74
|
else:
|
|
92
|
-
ctx["out"](f"{
|
|
75
|
+
ctx["out"](f"{format_scheduled_notification(schedule)}\n")
|
|
93
76
|
return 0
|
|
94
77
|
|
|
95
78
|
def handle_reset(rest, ctx):
|
package/src/commands/launch.py
CHANGED
|
@@ -6,6 +6,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
|
6
6
|
import os
|
|
7
7
|
import shlex
|
|
8
8
|
|
|
9
|
+
from ..agent_notify import notifications_enabled, provision
|
|
9
10
|
from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
|
|
10
11
|
from ..cli_helpers import (
|
|
11
12
|
API_SCHEMA_VERSION,
|
|
@@ -21,9 +22,10 @@ from ..cli_helpers import (
|
|
|
21
22
|
_write_update_notice,
|
|
22
23
|
)
|
|
23
24
|
from ..cli_render import _dim, _info, _success, _warn
|
|
25
|
+
from ..config import PROVIDER_CODEX
|
|
24
26
|
from ..context_store import install_context_for_session, write_context
|
|
25
27
|
from ..errors import CdxError
|
|
26
|
-
from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
|
|
28
|
+
from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def _format_resume_capability(capability, use_color=False):
|
|
@@ -108,6 +110,23 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
108
110
|
if not json_flag:
|
|
109
111
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
110
112
|
_write_update_notice(ctx)
|
|
113
|
+
installed = provision(
|
|
114
|
+
_get_auth_home(session),
|
|
115
|
+
session["provider"],
|
|
116
|
+
notifications_enabled(session),
|
|
117
|
+
ctx.get("env"),
|
|
118
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
119
|
+
base_dir=ctx["service"]["base_dir"],
|
|
120
|
+
)
|
|
121
|
+
if installed and not json_flag:
|
|
122
|
+
notice = f"Notification hooks installed for {session['name']}"
|
|
123
|
+
if session["provider"] == PROVIDER_CODEX:
|
|
124
|
+
# Codex refuses to run a hook it has not been told to trust, so
|
|
125
|
+
# silence here would leave the user with a feature that quietly does
|
|
126
|
+
# nothing. Claude Code honours them straight away — verified against
|
|
127
|
+
# a real turn — so it gets no instruction it does not need.
|
|
128
|
+
notice += " — approve them once in Codex to enable"
|
|
129
|
+
ctx["out"](f"{_info(notice, ctx['use_color'])}\n")
|
|
111
130
|
cwd = ctx.get("cwd") or os.getcwd()
|
|
112
131
|
runtime_run_id = None
|
|
113
132
|
|
package/src/notify.py
CHANGED
|
@@ -10,126 +10,51 @@ from .errors import CdxError
|
|
|
10
10
|
from .session_ranking import rank_sessions
|
|
11
11
|
from .status_view import (
|
|
12
12
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
|
|
13
|
-
_parse_reset_timestamp,
|
|
14
13
|
_priority_reset_timestamp,
|
|
15
14
|
)
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def parse_notify_args(args):
|
|
21
|
-
json_flag = "--json" in args
|
|
22
|
-
once = "--once" in args
|
|
23
|
-
at_reset = "--at-reset" in args
|
|
24
|
-
next_ready = "--next-ready" in args
|
|
25
|
-
refresh = "--refresh" in args
|
|
26
|
-
schedule = "--schedule" in args
|
|
27
|
-
poll = 60
|
|
28
|
-
cleaned = []
|
|
29
|
-
i = 0
|
|
30
|
-
while i < len(args):
|
|
31
|
-
arg = args[i]
|
|
32
|
-
if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
|
|
33
|
-
i += 1
|
|
34
|
-
continue
|
|
35
|
-
if arg == "--poll":
|
|
36
|
-
if i + 1 >= len(args):
|
|
37
|
-
raise CdxError(NOTIFY_USAGE)
|
|
38
|
-
try:
|
|
39
|
-
poll = max(1, int(args[i + 1]))
|
|
40
|
-
except ValueError as error:
|
|
41
|
-
raise CdxError("--poll must be a number of seconds") from error
|
|
42
|
-
i += 2
|
|
43
|
-
continue
|
|
44
|
-
if arg.startswith("--poll="):
|
|
45
|
-
try:
|
|
46
|
-
poll = max(1, int(arg.split("=", 1)[1]))
|
|
47
|
-
except ValueError as error:
|
|
48
|
-
raise CdxError("--poll must be a number of seconds") from error
|
|
49
|
-
i += 1
|
|
50
|
-
continue
|
|
51
|
-
if arg.startswith("-"):
|
|
52
|
-
raise CdxError(NOTIFY_USAGE)
|
|
53
|
-
cleaned.append(arg)
|
|
54
|
-
i += 1
|
|
55
|
-
if at_reset == next_ready:
|
|
56
|
-
raise CdxError(NOTIFY_USAGE)
|
|
57
|
-
if at_reset and len(cleaned) != 1:
|
|
58
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
59
|
-
if next_ready and cleaned:
|
|
60
|
-
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
61
|
-
return {
|
|
62
|
-
"name": cleaned[0] if cleaned else None,
|
|
63
|
-
"mode": "at-reset" if at_reset else "next-ready",
|
|
64
|
-
"poll": poll,
|
|
65
|
-
"once": once,
|
|
66
|
-
"json": json_flag,
|
|
67
|
-
"refresh": refresh,
|
|
68
|
-
"schedule": schedule,
|
|
69
|
-
}
|
|
16
|
+
READY_USAGE = "Usage: cdx ready [--refresh] [--json]"
|
|
70
17
|
|
|
71
18
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
notifier(event["title"], event["message"])
|
|
90
|
-
return event
|
|
91
|
-
if progress_callback:
|
|
92
|
-
progress_callback({
|
|
93
|
-
"event": "notify_waiting",
|
|
94
|
-
"message": event["message"],
|
|
95
|
-
"poll": parsed["poll"],
|
|
96
|
-
"session_name": event.get("session"),
|
|
97
|
-
"target_timestamp": event.get("target_timestamp"),
|
|
98
|
-
})
|
|
99
|
-
sleep_fn(parsed["poll"])
|
|
19
|
+
def parse_ready_args(args):
|
|
20
|
+
"""`cdx ready [--refresh] [--json]`.
|
|
21
|
+
|
|
22
|
+
This used to parse a much wider surface — --at-reset, --once, --poll and a
|
|
23
|
+
blocking wait loop — reachable through a `cdx notify` that no longer exists.
|
|
24
|
+
`cdx ready` always schedules the next-ready event, so none of it could run.
|
|
25
|
+
"""
|
|
26
|
+
unknown = [arg for arg in args if arg not in ("--refresh", "--json")]
|
|
27
|
+
if unknown:
|
|
28
|
+
raise CdxError(READY_USAGE)
|
|
29
|
+
return {
|
|
30
|
+
"name": None,
|
|
31
|
+
"mode": "next-ready",
|
|
32
|
+
"json": "--json" in args,
|
|
33
|
+
"refresh": "--refresh" in args,
|
|
34
|
+
"schedule": True,
|
|
35
|
+
}
|
|
100
36
|
|
|
101
37
|
|
|
102
38
|
def resolve_notify_event(rows, parsed, now_ts=None):
|
|
103
39
|
now_ts = time.time() if now_ts is None else now_ts
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
|
|
123
|
-
|
|
124
|
-
row = next((item for item in rows if item["session_name"] == parsed["name"]), None)
|
|
125
|
-
if not row:
|
|
126
|
-
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
127
|
-
timestamp = _next_reset_timestamp(row)
|
|
128
|
-
if timestamp is None:
|
|
129
|
-
return _event(False, "cdx", f"No reset time known for {row['session_name']}", row["session_name"])
|
|
130
|
-
if timestamp <= now_ts:
|
|
131
|
-
return _event(True, "cdx", f"{row['session_name']} reset is due", row["session_name"], timestamp)
|
|
132
|
-
return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
|
|
40
|
+
active_rows = [row for row in rows if row.get("enabled", True) is not False]
|
|
41
|
+
# Same ranking every other selector uses; the pre-filter is what makes
|
|
42
|
+
# this "next to come back" rather than "best right now".
|
|
43
|
+
priority, _decision = rank_sessions(
|
|
44
|
+
[
|
|
45
|
+
row for row in active_rows
|
|
46
|
+
if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
|
|
47
|
+
],
|
|
48
|
+
now_ts,
|
|
49
|
+
_priority_reset_timestamp,
|
|
50
|
+
)
|
|
51
|
+
if not priority:
|
|
52
|
+
return _event(False, "cdx", "No upcoming session reset available", None)
|
|
53
|
+
first = priority[0]
|
|
54
|
+
timestamp = _priority_reset_timestamp(first)
|
|
55
|
+
if _needs_refresh(first, timestamp, now_ts):
|
|
56
|
+
return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
|
|
57
|
+
return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
|
|
133
58
|
|
|
134
59
|
|
|
135
60
|
def _is_usable_now(row):
|
|
@@ -144,18 +69,6 @@ def _needs_refresh(row, reset_timestamp, now_ts):
|
|
|
144
69
|
return reset_timestamp is not None and reset_timestamp < now_ts
|
|
145
70
|
|
|
146
71
|
|
|
147
|
-
def _next_reset_timestamp(row):
|
|
148
|
-
values = [row.get("reset_5h_at"), row.get("reset_week_at"), row.get("reset_at")]
|
|
149
|
-
timestamps = [
|
|
150
|
-
timestamp
|
|
151
|
-
for timestamp in (_parse_reset_timestamp(value) for value in values)
|
|
152
|
-
if timestamp is not None
|
|
153
|
-
]
|
|
154
|
-
if not timestamps:
|
|
155
|
-
return None
|
|
156
|
-
return min(timestamps)
|
|
157
|
-
|
|
158
|
-
|
|
159
72
|
def _event(ready, title, message, session_name, target_timestamp=None):
|
|
160
73
|
return {
|
|
161
74
|
"ready": ready,
|
|
@@ -166,22 +79,70 @@ def _event(ready, title, message, session_name, target_timestamp=None):
|
|
|
166
79
|
}
|
|
167
80
|
|
|
168
81
|
|
|
169
|
-
def
|
|
82
|
+
def notification_channel(env=None):
|
|
83
|
+
"""Name the delivery mechanism usable on this host, or None if there is none.
|
|
84
|
+
|
|
85
|
+
Two callers depend on this answer: `cdx notify` decides whether to bother
|
|
86
|
+
delivering, and launch provisioning decides whether to install hooks at all.
|
|
87
|
+
A host with no channel gets no hooks, so the user is never asked to approve
|
|
88
|
+
a hook that could not have shown them anything.
|
|
89
|
+
"""
|
|
170
90
|
import sys
|
|
171
|
-
spawn_sync = spawn_sync or subprocess.run
|
|
172
91
|
env = env or os.environ
|
|
92
|
+
path = env.get("PATH")
|
|
173
93
|
if sys.platform == "win32":
|
|
174
|
-
|
|
175
|
-
|
|
94
|
+
return "powershell" if shutil.which("powershell", path=path) else None
|
|
95
|
+
if _is_wsl(env):
|
|
96
|
+
# WSL has no notification daemon of its own; delivery crosses to Windows
|
|
97
|
+
# through interop, which /etc/wsl.conf can disable.
|
|
98
|
+
return "wsl" if shutil.which("powershell.exe", path=path) else None
|
|
99
|
+
if shutil.which("osascript", path=path):
|
|
100
|
+
return "osascript" if _has_desktop_session(env) else None
|
|
101
|
+
if shutil.which("notify-send", path=path):
|
|
102
|
+
return "notify-send" if _has_desktop_session(env) else None
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_wsl(env):
|
|
107
|
+
if env.get("WSL_DISTRO_NAME") or env.get("WSL_INTEROP"):
|
|
108
|
+
return True
|
|
109
|
+
try:
|
|
110
|
+
with open("/proc/version", encoding="utf-8") as handle:
|
|
111
|
+
return "microsoft" in handle.read().lower()
|
|
112
|
+
except OSError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _has_desktop_session(env):
|
|
117
|
+
"""Whether anything is around to render a notification.
|
|
118
|
+
|
|
119
|
+
Over SSH or in a container there is no session bus and no display, so
|
|
120
|
+
notify-send is delivered nowhere and osascript has no session to talk to.
|
|
121
|
+
"""
|
|
122
|
+
import sys
|
|
123
|
+
if sys.platform == "darwin":
|
|
124
|
+
return not env.get("SSH_CONNECTION")
|
|
125
|
+
return bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY") or env.get("DBUS_SESSION_BUS_ADDRESS"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
129
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
130
|
+
env = env or os.environ
|
|
131
|
+
channel = notification_channel(env)
|
|
132
|
+
if channel == "powershell":
|
|
133
|
+
_send_windows_notification(title, message, spawn_sync, env, ["powershell"])
|
|
134
|
+
elif channel == "wsl":
|
|
135
|
+
_send_windows_notification(title, message, spawn_sync, env, ["powershell.exe"])
|
|
136
|
+
elif channel == "osascript":
|
|
176
137
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
177
138
|
_run_notification_command(
|
|
178
139
|
["osascript", "-e", script],
|
|
179
140
|
spawn_sync,
|
|
180
141
|
env,
|
|
181
142
|
)
|
|
182
|
-
elif
|
|
143
|
+
elif channel == "notify-send":
|
|
183
144
|
_run_notification_command(
|
|
184
|
-
["notify-send", str(title), str(message)],
|
|
145
|
+
["notify-send", "-a", "cdx", str(title), str(message)],
|
|
185
146
|
spawn_sync,
|
|
186
147
|
env,
|
|
187
148
|
)
|
|
@@ -190,26 +151,45 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
|
190
151
|
def _run_notification_command(argv, spawn_sync, env):
|
|
191
152
|
try:
|
|
192
153
|
spawn_sync(argv, env=env, capture_output=True, text=True, timeout=5)
|
|
193
|
-
except (FileNotFoundError, OSError):
|
|
154
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
194
155
|
pass
|
|
195
156
|
|
|
196
157
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
158
|
+
# Windows only shows a toast on behalf of an application it knows. Registering our
|
|
159
|
+
# own identifier would mean installing a Start Menu shortcut, so we borrow the one
|
|
160
|
+
# PowerShell already ships with; an unknown identifier is dropped in silence.
|
|
161
|
+
_POWERSHELL_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
162
|
+
|
|
163
|
+
_TOAST_SCRIPT = """
|
|
164
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null
|
|
165
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null
|
|
166
|
+
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
167
|
+
$nodes = $template.GetElementsByTagName('text')
|
|
168
|
+
$nodes.Item(0).AppendChild($template.CreateTextNode('{title}')) > $null
|
|
169
|
+
$nodes.Item(1).AppendChild($template.CreateTextNode('{message}')) > $null
|
|
170
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
|
|
171
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{aumid}').Show($toast)
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _send_windows_notification(title, message, spawn_sync, env, launcher):
|
|
176
|
+
"""Raise a toast rather than a dialog.
|
|
177
|
+
|
|
178
|
+
The dialog this replaced blocked with no timeout, which was survivable for a
|
|
179
|
+
once-a-day quota alert and not for a hook that fires on every agent turn: an
|
|
180
|
+
undismissed window holds the turn open, and one that steals focus every turn
|
|
181
|
+
is worse than no notification at all.
|
|
182
|
+
"""
|
|
183
|
+
script = _TOAST_SCRIPT.format(
|
|
184
|
+
title=_escape_powershell(title),
|
|
185
|
+
message=_escape_powershell(message),
|
|
186
|
+
aumid=_POWERSHELL_AUMID,
|
|
187
|
+
)
|
|
188
|
+
_run_notification_command(
|
|
189
|
+
[*launcher, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
190
|
+
spawn_sync,
|
|
191
|
+
env,
|
|
203
192
|
)
|
|
204
|
-
try:
|
|
205
|
-
spawn_sync(
|
|
206
|
-
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
207
|
-
env=env,
|
|
208
|
-
capture_output=True,
|
|
209
|
-
text=True,
|
|
210
|
-
)
|
|
211
|
-
except (FileNotFoundError, OSError):
|
|
212
|
-
pass
|
|
213
193
|
|
|
214
194
|
|
|
215
195
|
def _escape_powershell(value):
|
|
@@ -437,10 +417,6 @@ def _escape_xml(value):
|
|
|
437
417
|
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
438
418
|
|
|
439
419
|
|
|
440
|
-
def format_notify_event(event):
|
|
441
|
-
return event["message"]
|
|
442
|
-
|
|
443
|
-
|
|
444
420
|
def format_scheduled_notification(schedule):
|
|
445
421
|
if schedule.get("scheduled"):
|
|
446
422
|
return f"Scheduled notification via {schedule['backend']} for {schedule['target_iso']}"
|
package/src/provider_runtime.py
CHANGED
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
import uuid
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
|
|
13
|
+
from .agent_notify import launch_notify_env, notifications_enabled
|
|
13
14
|
from .claude_usage import _clean_oauth_token, _decode_jwt_claims
|
|
14
15
|
from .codex_usage import codex_auth_lock
|
|
15
16
|
from .config import (
|
|
@@ -517,6 +518,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
517
518
|
cwd = cwd or os.getcwd()
|
|
518
519
|
env_override = env_override or {}
|
|
519
520
|
env = {**os.environ, **env_override}
|
|
521
|
+
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
520
522
|
initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
|
|
521
523
|
_validate_initial_prompt(initial_prompt)
|
|
522
524
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
@@ -724,6 +726,9 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
724
726
|
_validate_initial_prompt(initial_prompt)
|
|
725
727
|
cwd = cwd or os.getcwd()
|
|
726
728
|
env = {**os.environ, **(env_override or {})}
|
|
729
|
+
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
730
|
+
# launch. Their caller already learns of completion from the return value.
|
|
731
|
+
env.update(launch_notify_env(session, enabled=False))
|
|
727
732
|
initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
|
|
728
733
|
_validate_initial_prompt(initial_prompt)
|
|
729
734
|
launch = session.get("launch") or {}
|