cdx-manager 0.15.1 → 0.17.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/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]]
@@ -48,7 +48,7 @@ from .commands.auth import (
48
48
  handle_logout as handle_logout,
49
49
  )
50
50
  from .commands.auth import (
51
- handle_notify as handle_notify,
51
+ handle_ready as handle_ready,
52
52
  )
53
53
  from .commands.auth import (
54
54
  handle_reset as handle_reset,
@@ -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 == "":
@@ -1,4 +1,4 @@
1
- """Authentication domain commands: login, logout, reset, notify.
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
- parse_notify_args,
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 handle_notify(rest, ctx):
39
- parsed = parse_notify_args(rest)
36
+ def handle_ready(rest, ctx):
37
+ """`cdx ready`: schedule an OS notification for the next session to come back.
40
38
 
41
- def notifier(title, message):
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
- message,
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
- if parsed["schedule"]:
50
- event = resolve_notify_event(
51
- ctx["service"]["get_status_rows"](
52
- progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
53
- force_refresh=parsed.get("refresh", False),
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
- (ctx["options"].get("now") or time.time)(),
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", "Resolved notification event", event=event))
73
+ _write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
91
74
  else:
92
- ctx["out"](f"{format_notify_event(event)}\n")
75
+ ctx["out"](f"{format_scheduled_notification(schedule)}\n")
93
76
  return 0
94
77
 
95
78
  def handle_reset(rest, ctx):
@@ -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
 
@@ -128,6 +147,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
128
147
  session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
129
148
  signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
130
149
  lifecycle_callback=runtime_lifecycle,
150
+ # A JSON run owns stdout: a terminal title sequence would land in
151
+ # the middle of the document its caller has to parse.
152
+ title_enabled=not json_flag,
131
153
  )
132
154
  except CdxError as error:
133
155
  run_info = getattr(error, "run_info", {}) or {}
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
- NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
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 wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None, progress_callback=None):
73
- notifier = notifier or send_desktop_notification
74
- sleep_fn = sleep_fn or time.sleep
75
- now_fn = now_fn or time.time
76
- while True:
77
- if progress_callback:
78
- progress_callback({"event": "notify_check_started", "mode": parsed["mode"], "session_name": parsed["name"]})
79
- event = resolve_notify_event(
80
- service["get_status_rows"](
81
- progress_callback=progress_callback,
82
- force_refresh=parsed.get("refresh", False),
83
- ),
84
- parsed,
85
- now_fn(),
86
- )
87
- if event["ready"] or parsed["once"]:
88
- if event["ready"]:
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 parsed["mode"] == "next-ready":
105
- active_rows = [row for row in rows if row.get("enabled", True) is not False]
106
- # Same ranking every other selector uses; the pre-filter is what makes
107
- # this "next to come back" rather than "best right now".
108
- priority, _decision = rank_sessions(
109
- [
110
- row for row in active_rows
111
- if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
112
- ],
113
- now_ts,
114
- _priority_reset_timestamp,
115
- )
116
- if not priority:
117
- return _event(False, "cdx", "No upcoming session reset available", None)
118
- first = priority[0]
119
- timestamp = _priority_reset_timestamp(first)
120
- if _needs_refresh(first, timestamp, now_ts):
121
- return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
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 send_desktop_notification(title, message, spawn_sync=None, env=None):
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
- _send_windows_notification(title, message, spawn_sync, env)
175
- elif shutil.which("osascript", path=env.get("PATH")):
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 shutil.which("notify-send", path=env.get("PATH")):
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
- def _send_windows_notification(title, message, spawn_sync, env):
198
- title_escaped = _escape_powershell(title)
199
- message_escaped = _escape_powershell(message)
200
- script = (
201
- "Add-Type -AssemblyName System.Windows.Forms; "
202
- f"[System.Windows.Forms.MessageBox]::Show('{message_escaped}', '{title_escaped}')"
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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']}"