@ciphore/radiocli 0.2.1 → 0.2.3

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,137 @@ All notable changes to RadioCLI are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.3] - 2026-09-07
9
+
10
+ ### Added
11
+
12
+ - Added opt-in local coding-agent control through both a typed stdio MCP server
13
+ and `radiocli agent` shell commands, covering discovery, playback, favorites,
14
+ listening stats, appearance, and configurable task-completion actions.
15
+ - Added automatic MCP setup for detected Codex, Claude Code, OpenCode, Cursor,
16
+ Gemini CLI, VS Code/Copilot Agent Host, and Orca-backed runtimes, plus a
17
+ portable configuration for other MCP clients.
18
+ - Added typed MCP and voice-accessible alarm management, active ringing
19
+ controls, and macOS AirPlay discovery, receiver selection, local-output
20
+ switching, and ephemeral passcode submission.
21
+ - Added `radiocli update --install`, package-manager-aware upgrade commands,
22
+ an in-app update indicator, read-only MCP update status, and
23
+ `radiocli mcp repair`.
24
+ - Added Agent settings for installing or repairing MCP integrations and choosing
25
+ between a visible terminal player and headless playback.
26
+ - Added the animated Gallop receiver as the new default and a color-field
27
+ Crossfade receiver.
28
+
29
+ ### Fixed
30
+
31
+ - Agent playback continues to open the terminal TUI by default on every platform,
32
+ while interactive setup now explains the macOS Automation prompt and offers an
33
+ explicit headless opt-out. MCP guidance prevents ordinary task-completion radio
34
+ actions from creating implicit goals or monitors.
35
+ - MCP registrations now use upgrade-stable, directly executable launchers,
36
+ including a shell-free Node command on Windows, and enabled registrations are
37
+ repaired after an in-app or CLI update.
38
+ - Active interactive or agent playback now yields cleanly when an alarm begins,
39
+ preventing overlapping streams while preserving the alarm if TUI handoff fails.
40
+ - Kept the Countries station-count column fixed while scrolling, added focused
41
+ marquee reading for overlong country and station names across Explore,
42
+ Library, and Nearby, and aligned Overview descriptions behind a compact
43
+ separator-free title column.
44
+
45
+ ### Security
46
+
47
+ - Agent playback uses a single-owner, authenticated loopback session with
48
+ serialized commands, private discovery files, and strict isolation from
49
+ active alarm sessions. Agent tools accept saved or RadioCLI-discovered station
50
+ IDs rather than arbitrary stream URLs.
51
+ - Alarm removal requires an exact ID and explicit confirmation. AirPlay receiver
52
+ IDs must come from local discovery, and receiver passcodes are never persisted
53
+ or echoed by agent responses.
54
+
55
+ ## [0.2.2] - 2026-09-04
56
+
57
+ ### Added
58
+
59
+ - Added one-time and IANA-timezone recurring radio alarms under Overview, with a
60
+ full-screen TUI editor, station picker, next-run preview, enable/disable,
61
+ delete confirmation, test tune, repair, and active ringing controls.
62
+ - Added native background registration through macOS launchd, Windows Task
63
+ Scheduler, and systemd user timers on Linux, plus `radiocli alarm` commands
64
+ for scripting, inspection, health checks, synchronization, and playback
65
+ control.
66
+ - Added optional per-alarm wake requests where the OS supports them and a
67
+ separate cross-platform Alarm Guard that prevents idle sleep until the next
68
+ occurrence.
69
+ - Added automatic ringing-control launch in the terminal captured when an alarm
70
+ is synchronized, with reuse of an already-running RadioCLI TUI.
71
+ - Added a navigable **Verify alarm setup** rehearsal that exercises a disposable
72
+ native job, the saved terminal, authenticated controls, sleep protection,
73
+ system volume, and a three-second configured-station sample before cleanup.
74
+ - Added `radiocli setup`, an interactive installer that detects supported
75
+ package managers, previews required playback tools, supports dry runs, guides
76
+ installation, and verifies the resulting local playback configuration.
77
+ - Added an adaptive Explore experience with a responsive Cosmo world map,
78
+ height-aware station lists in micro terminals, pointer-accurate map
79
+ selection, and safe scrolling marquees for long station details.
80
+ - Added richer `radiocli doctor` playback diagnostics, including the resolved
81
+ mpv path, discovery source, launchability, version output, and actionable
82
+ launch errors. Windows discovery now covers package-manager shims, common
83
+ application directories, and registered mpv installations.
84
+
85
+ ### Changed
86
+
87
+ - Full library backup and restore now carries alarm definitions and latest run outcomes;
88
+ native jobs, Alarm Guard state, runtime health, and local control credentials
89
+ remain specific to each machine and are rebuilt by alarm synchronization.
90
+ - macOS terminal Automation permission is requested during alarm setup, and a
91
+ TUI opened by a ringing alarm now protects the active native job from startup,
92
+ repair, edit, and delete reconciliation until playback ends.
93
+ - Snooze now closes the completed ringing session, returns to Alarms, and shows
94
+ the next ring time. Alarm playback temporarily unmutes and raises a low local
95
+ system output to the configured alarm level on macOS, Windows, and supported
96
+ Linux audio stacks, restoring the previous state afterward.
97
+ - Alarm list and editor presentation is more concise: historical dismissal text,
98
+ duplicated controls, and long scheduler caveats were removed; specific field
99
+ guidance is now inline and Save is presented as a display-color action.
100
+ - Alarm ringing controls now use Enter to transfer the station into normal
101
+ interactive playback and Space to stop and snooze for ten minutes. The alarm
102
+ UI and documentation identify scheduling as experimental beta functionality,
103
+ explain when the terminal may close, and recommend a secondary device for
104
+ critical wake-ups.
105
+ - Playback-backend guidance now directs users to `radiocli setup` when local
106
+ tools are missing, with updated CLI help, setup documentation, and status
107
+ messages covering the guided workflow.
108
+ - Receiver animation updates are isolated from the rest of the application,
109
+ respect reduced-motion preferences, and use cached visualizer geometry and
110
+ compact color runs to reduce rendering work without changing visual output.
111
+ - Radio Browser requests now use atlas-aware timeouts and retain clearer
112
+ provider failure details when station directories are slow or unavailable.
113
+
114
+ ### Fixed
115
+
116
+ - Alarm lists now scroll long text only on the focused interactive row, keep
117
+ selection and enabled-state markers fixed, and avoid repeating station and
118
+ next-run details. Compact and micro layouts use shorter next-wake, scheduler,
119
+ and beta-safety messages instead of clipping desktop-length copy.
120
+ - Footer shortcut legends now balance actions across every available row,
121
+ reserve an additional row in compact terminals when space permits, and show
122
+ concise key-only hints in micro layouts instead of ending in truncated text.
123
+ The version and update indicator remains independently anchored at bottom right.
124
+ - Rapid keyboard and mouse-wheel navigation now commits the selected row
125
+ immediately, cancels pending automatic station skips, and preserves alarm
126
+ editor scrolling; search input keeps ownership of typed alarm-shortcut
127
+ characters.
128
+ - Player shutdown now coalesces concurrent stop requests, while delayed
129
+ automatic skipping can be canceled during retuning to avoid duplicate
130
+ process termination and unintended station changes.
131
+ - Explore maps, station lists, and screen rendering now remain aligned and
132
+ navigable across compact and true-micro terminal sizes, with improved map
133
+ centering, cursor mapping, and visible provider errors.
134
+
135
+ ### Security
136
+
137
+ - Active alarm controls and Alarm Guard ownership checks use authenticated
138
+ loopback-only endpoints with machine-local private token files.
9
139
 
10
140
  ## [0.2.1] - 2026-08-22
11
141
 
@@ -265,6 +395,8 @@ Initial public release.
265
395
  [0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
266
396
  [0.2.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.0
267
397
  [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1
398
+ [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2
399
+ [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3
268
400
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
269
401
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
270
402
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -18,9 +18,11 @@ Built with [Ink](https://github.com/vadimdemedes/ink),
18
18
  - Discover stations through Explore, Search, Countries, or approximate-location Nearby.
19
19
  - Listen with a receiver-style Now Playing screen, live metadata, sleep timer, diagnostics, and selectable visualizers.
20
20
  - Keep favorites, recents, imports, track history, and listening stats on your machine.
21
+ - Create one-time or recurring radio alarms from the TUI, with native background
22
+ scheduling, station fallback, missed-run grace, snooze, and optional Alarm Guard.
21
23
  - Move through the exact station list you tuned from with previous and next controls.
22
24
  - Use `mpv` for complete playback control, with `ffplay` and VLC as limited fallbacks.
23
- - Resize freely: full, compact, and micro layouts preserve navigation and essential controls.
25
+ - Resize freely: full, compact, and micro layouts preserve navigation and essential controls; Explore retains its interactive world map at every usable size.
24
26
 
25
27
  ## Quick start
26
28
 
@@ -35,13 +37,14 @@ macOS, Linux, or Windows with npm:
35
37
 
36
38
  ```bash
37
39
  npm install -g @ciphore/radiocli
38
- radiocli doctor
40
+ radiocli setup
39
41
  radiocli
40
42
  ```
41
43
 
42
44
  RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and
43
- FFmpeg; npm installs JavaScript dependencies only, so install `mpv` with your
44
- system package manager for pause, volume, mute, media keys, and metadata.
45
+ FFmpeg. After an npm install, `radiocli setup` detects the operating system and
46
+ package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected
47
+ native tools with branded progress feedback, and verifies playback readiness.
45
48
 
46
49
  See the [installation guide](apps/docs/content/docs/getting-started/install.mdx)
47
50
  for Windows, Linux distributions, AirPlay prerequisites, and fallback players.
@@ -91,6 +94,24 @@ covers filters, playback, media-key learning, AirPlay, and every command.
91
94
 
92
95
  ## CLI
93
96
 
97
+ RadioCLI can also be controlled by local coding agents. Run `radiocli setup`
98
+ and opt into MCP, or use `radiocli setup --mcp`, to configure detected Codex,
99
+ Claude, OpenCode, Cursor, Gemini, VS Code/Copilot, and Orca-backed runtimes
100
+ automatically. From the TUI, open **Settings → Agent control & MCP** and turn on
101
+ **Allow local agent control** for the same one-step setup—no terminal command is
102
+ required. The same actions are
103
+ available to non-MCP harnesses through `radiocli agent` and `radiocli alarm`:
104
+ search and play stations, browse recents/favorites/nearby stations, favorite the
105
+ current station, inspect listening stats, create and control alarms, control
106
+ macOS AirPlay, change the receiver look, or run a user-configured completion
107
+ preset. Codex Voice can invoke those same typed MCP tools. Agent playback opens
108
+ the normal RadioCLI TUI in a terminal window by default on macOS, Windows, and
109
+ Linux. Interactive setup explains that macOS may require the agent host to approve
110
+ a one-time Automation prompt; users can explicitly opt into headless playback.
111
+ Scheduled alarms remain isolated and always use local output rather than
112
+ unattended AirPlay. See the
113
+ [CLI guide](apps/docs/content/docs/getting-started/cli.mdx#local-agent-and-mcp-control).
114
+
94
115
  ```bash
95
116
  radiocli # Start the TUI
96
117
  radiocli check # Check providers, playback tools, and the local store
@@ -100,11 +121,46 @@ radiocli countries
100
121
  radiocli import stations.m3u
101
122
  radiocli export favorites.m3u
102
123
  radiocli add-url <stream-url> [station name]
124
+ radiocli alarm list
125
+ radiocli alarm doctor
126
+ radiocli update --install # Upgrade and repair enabled MCP registrations
103
127
  ```
104
128
 
129
+ Existing installations keep their library and preferences when upgraded. Use
130
+ `radiocli update` to inspect the detected Homebrew, npm, pnpm, or Bun command,
131
+ or `radiocli update --install` to run it. MCP registrations use an
132
+ upgrade-stable launcher so versioned package paths do not break them. On
133
+ Windows this is a shell-free Node command; on Unix it uses the `radiocli` shim.
134
+ Run `radiocli mcp status` to inspect registrations and `radiocli mcp repair`
135
+ after moving an installation or when an agent client reports a stale server;
136
+ fully quit and reopen agent clients after enabling, repairing, or upgrading.
137
+ Until that restart, even a newly created task in an already-running client will
138
+ not have RadioCLI's tools and may incorrectly fall back to browser playback.
139
+
105
140
  RadioCLI imports `.m3u`, `.pls`, and `.xspf` playlists. It exports favorites
106
141
  and imported streams as `.m3u`.
107
142
 
143
+ Alarms are experimental beta functionality and live under **Overview**,
144
+ immediately before Settings. Saving an enabled
145
+ alarm registers it with launchd on macOS, a systemd user timer on Linux, or Task
146
+ Scheduler on Windows, so the terminal does not need to remain open. When it
147
+ fires, RadioCLI reopens the saved supported terminal and shows the ringing
148
+ screen. Enter transfers the station into normal interactive playback; Space
149
+ stops it and snoozes for 10 minutes. If a
150
+ RadioCLI TUI is already open, that instance shows the controls instead. The
151
+ computer must still be powered on with a logged-in interactive audio session; wake timing
152
+ depends on hardware and OS power policy. See the [CLI guide](apps/docs/content/docs/getting-started/cli.mdx)
153
+ and [reliability notes](apps/docs/content/docs/reliability.mdx) before relying on
154
+ an alarm for something critical. Use a secondary device as the primary alarm
155
+ for safety-critical, medical, travel, or emergency timing. See the dedicated
156
+ [Alarms guide](apps/docs/content/docs/alarms.mdx) for the full workflow.
157
+
158
+ The **Verify alarm setup** row performs a disposable native-scheduler
159
+ registration, an authenticated terminal/control handshake, sleep-inhibitor and
160
+ system-volume checks, and a short sample from the next configured alarm. It
161
+ cleans up the temporary job and reports hard blockers separately from hardware-
162
+ or policy-dependent wake limitations.
163
+
108
164
  See the [CLI guide](apps/docs/content/docs/getting-started/cli.mdx) for all
109
165
  arguments and examples.
110
166
 
@@ -120,14 +176,27 @@ Playback waits for backend readiness, applies tune timeouts, and can skip broken
120
176
  streams. Corrupt library and cache files are backed up instead of silently
121
177
  overwritten.
122
178
 
179
+ Scheduled playback retries the primary station once, then tries an optional
180
+ fallback. A missed-run grace window controls catch-up after sleep or logout.
181
+ True fade-in requires `mpv`; `ffplay` and VLC start audibly at the configured
182
+ target volume. Scheduled alarms always use local speakers, never unattended
183
+ AirPlay.
184
+
185
+ On macOS, an npm or source installation is executed by Node, so System Settings
186
+ may identify its background item as `node`. Showing RadioCLI as the providing
187
+ application requires a future signed macOS app bundle with a bundled helper;
188
+ changing a launchd label cannot safely relabel the executable.
189
+
123
190
  Read the [reliability notes](apps/docs/content/docs/reliability.mdx) for failure
124
191
  handling and troubleshooting.
125
192
 
126
193
  ## Privacy
127
194
 
128
195
  RadioCLI does not require an account, proxy audio, or upload your listening
129
- history. Favorites, recents, imports, settings, track history, and activity stay
130
- in a local JSON library.
196
+ history. Favorites, recents, imports, settings, alarms, track history, and
197
+ activity stay in a local JSON library. Full JSON library backups include alarm
198
+ definitions, while native scheduler registrations, runtime health, Alarm Guard
199
+ state, and authenticated loopback-control tokens remain machine-local.
131
200
 
132
201
  Nearby contacts `ipapi.co` only when you open that screen, then uses an
133
202
  approximate location to sort the local station atlas. Disable the lookup with
@@ -145,6 +214,7 @@ complete data-flow description.
145
214
  - `src/ui` — screens, input, layout, and terminal rendering
146
215
  - `src/providers` — station directories, resolution, and caches
147
216
  - `src/player` — playback backends, metadata, and AirPlay
217
+ - `src/alarms` — schedules, native registration, Alarm Guard, and active controls
148
218
  - `src/storage` — local library persistence and migration
149
219
  - `apps/docs` — documentation website and manual
150
220
 
@@ -0,0 +1,210 @@
1
+ import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm } from '../alarms/schedule.js';
2
+ import { connectActiveAlarms } from '../alarms/active-session.js';
3
+ import { createSchedulerService } from '../alarms/scheduler.js';
4
+ export class AgentAlarmService {
5
+ store;
6
+ resolveStation;
7
+ scheduler;
8
+ handoffToInteractive;
9
+ constructor(store, resolveStation, scheduler = createSchedulerService(), handoffToInteractive) {
10
+ this.store = store;
11
+ this.resolveStation = resolveStation;
12
+ this.scheduler = scheduler;
13
+ this.handoffToInteractive = handoffToInteractive;
14
+ }
15
+ async list() {
16
+ return Promise.all(this.store.listAlarms().map(alarm => this.describe(alarm)));
17
+ }
18
+ async status() {
19
+ const alarms = this.store.listAlarms();
20
+ return {
21
+ active: await this.activeStatuses(),
22
+ scheduler: await this.scheduler.runtimeStatus(alarms),
23
+ alarms: await Promise.all(alarms.map(alarm => this.describe(alarm)))
24
+ };
25
+ }
26
+ async create(input) {
27
+ const station = await this.requiredStation(input.stationId);
28
+ const fallbackStation = input.fallbackStationId
29
+ ? await this.requiredStation(input.fallbackStationId)
30
+ : undefined;
31
+ const alarmInput = {
32
+ label: cleanLabel(input.label ?? station.name),
33
+ enabled: input.enabled ?? true,
34
+ station,
35
+ schedule: normalizeSchedule(input.schedule),
36
+ playback: {
37
+ volume: boundedInteger(input.volume ?? 40, 'volume', 0, 100),
38
+ fadeSeconds: boundedInteger(input.fadeSeconds ?? 0, 'fadeSeconds', 0, 3600),
39
+ stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? 60, 'stopAfterMinutes', 1, 10080),
40
+ ...(fallbackStation ? { fallbackStation } : {})
41
+ },
42
+ reliability: {
43
+ missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? 10, 'missedRunGraceMinutes', 0, 10080),
44
+ wakeIfSupported: input.wakeIfSupported ?? false,
45
+ keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false
46
+ }
47
+ };
48
+ const alarm = this.store.addAlarm(alarmInput);
49
+ await this.syncSaved(alarm);
50
+ return this.describe(alarm);
51
+ }
52
+ async update(id, input) {
53
+ const alarm = this.requiredAlarm(id);
54
+ const station = input.stationId ? await this.requiredStation(input.stationId) : alarm.station;
55
+ const fallbackStation = input.clearFallback
56
+ ? undefined
57
+ : input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation;
58
+ const updated = this.store.updateAlarm(alarm.id, {
59
+ label: input.label === undefined ? alarm.label : cleanLabel(input.label),
60
+ enabled: input.enabled ?? alarm.enabled,
61
+ station,
62
+ schedule: input.schedule ? normalizeSchedule(input.schedule) : alarm.schedule,
63
+ playback: {
64
+ volume: boundedInteger(input.volume ?? alarm.playback.volume, 'volume', 0, 100),
65
+ fadeSeconds: boundedInteger(input.fadeSeconds ?? alarm.playback.fadeSeconds, 'fadeSeconds', 0, 3600),
66
+ stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? alarm.playback.stopAfterMinutes, 'stopAfterMinutes', 1, 10080),
67
+ ...(fallbackStation ? { fallbackStation } : {})
68
+ },
69
+ reliability: {
70
+ missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? alarm.reliability.missedRunGraceMinutes, 'missedRunGraceMinutes', 0, 10080),
71
+ wakeIfSupported: input.wakeIfSupported ?? alarm.reliability.wakeIfSupported,
72
+ keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? alarm.reliability.keepAwakeUntilAlarm
73
+ }
74
+ });
75
+ await this.syncSaved(updated);
76
+ return this.describe(updated);
77
+ }
78
+ async setEnabled(id, enabled) {
79
+ const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
80
+ await this.syncSaved(alarm);
81
+ return this.describe(alarm);
82
+ }
83
+ async remove(id, confirm) {
84
+ if (!confirm)
85
+ throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.');
86
+ const alarm = this.requiredAlarm(id);
87
+ await this.scheduler.remove(alarm.id);
88
+ if (!this.store.removeAlarm(alarm.id))
89
+ throw new Error(`Alarm not found: ${id}`);
90
+ return { ok: true, removed: alarm.id };
91
+ }
92
+ async sync() {
93
+ return this.scheduler.syncAll(this.store.listAlarms());
94
+ }
95
+ async controlActive(input) {
96
+ const { client, status } = await this.selectActive(input.alarmId, input.occurrenceAt);
97
+ if (input.action === 'dismiss')
98
+ await client.dismiss();
99
+ else if (input.action === 'keep-playing')
100
+ await client.keepPlaying();
101
+ else if (input.action === 'handoff') {
102
+ if (status.state !== 'playing')
103
+ throw new Error('Alarm playback is still starting.');
104
+ if (!this.handoffToInteractive)
105
+ throw new Error('Interactive playback handoff is unavailable.');
106
+ await this.handoffToInteractive(status);
107
+ await client.handoff();
108
+ }
109
+ else
110
+ await client.snooze(boundedInteger(input.snoozeMinutes ?? 10, 'snoozeMinutes', 1, 1440));
111
+ return { ok: true, action: input.action, alarm: activeStatusForAgent(status) };
112
+ }
113
+ async describe(alarm) {
114
+ return {
115
+ id: alarm.id,
116
+ label: alarm.label,
117
+ enabled: alarm.enabled,
118
+ station: { id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name },
119
+ schedule: alarm.schedule,
120
+ nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null,
121
+ playback: {
122
+ volume: alarm.playback.volume,
123
+ fadeSeconds: alarm.playback.fadeSeconds,
124
+ stopAfterMinutes: alarm.playback.stopAfterMinutes,
125
+ ...(alarm.playback.fallbackStation ? { fallbackStation: {
126
+ id: `${alarm.playback.fallbackStation.provider}:${alarm.playback.fallbackStation.id}`,
127
+ name: alarm.playback.fallbackStation.name
128
+ } } : {})
129
+ },
130
+ reliability: alarm.reliability,
131
+ lastRun: alarm.lastRun,
132
+ snoozedUntil: alarm.nextOverride?.at
133
+ };
134
+ }
135
+ async activeStatuses() {
136
+ return (await Promise.all((await connectActiveAlarms()).map(client => client.status()))).map(activeStatusForAgent);
137
+ }
138
+ async selectActive(alarmId, occurrenceAt) {
139
+ const matches = [];
140
+ for (const client of await connectActiveAlarms()) {
141
+ const status = await client.status();
142
+ if ((!alarmId || status.alarmId === alarmId) && (!occurrenceAt || status.scheduledAt === occurrenceAt))
143
+ matches.push({ client, status });
144
+ }
145
+ if (!matches.length)
146
+ throw new Error('No matching alarm is currently ringing.');
147
+ if (matches.length > 1)
148
+ throw new Error('Multiple alarms are ringing. List alarm status, then provide alarm_id and optionally occurrence_at.');
149
+ return matches[0];
150
+ }
151
+ requiredAlarm(id) {
152
+ const alarm = this.store.getAlarm(id);
153
+ if (!alarm)
154
+ throw new Error(`Alarm not found: ${id}`);
155
+ return alarm;
156
+ }
157
+ async requiredStation(id) {
158
+ const station = await this.resolveStation(id);
159
+ if (!station)
160
+ throw new Error(`Unknown station ID: ${id}. Search or browse first.`);
161
+ return station;
162
+ }
163
+ async syncSaved(alarm) {
164
+ try {
165
+ await this.scheduler.sync(alarm);
166
+ }
167
+ catch (error) {
168
+ throw new Error(`Alarm ${alarm.id} was saved, but scheduler setup is degraded: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ }
171
+ }
172
+ function normalizeSchedule(schedule) {
173
+ if (schedule.type === 'once') {
174
+ const instant = new Date(schedule.at);
175
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(schedule.at) || !Number.isFinite(instant.getTime()) || instant.getUTCSeconds() !== 0 || instant.getUTCMilliseconds() !== 0) {
176
+ throw new Error('One-time alarms require an absolute ISO-8601 minute with an offset or Z and zero seconds.');
177
+ }
178
+ if (instant.getTime() <= Date.now())
179
+ throw new Error('One-time alarms must be scheduled in the future.');
180
+ return { type: 'once', at: instant.toISOString() };
181
+ }
182
+ return {
183
+ type: 'recurring',
184
+ time: canonicalizeAlarmTime(schedule.time),
185
+ weekdays: canonicalizeIsoWeekdays(schedule.weekdays),
186
+ timezone: canonicalizeTimeZone(schedule.timezone)
187
+ };
188
+ }
189
+ function boundedInteger(value, label, min, max) {
190
+ if (!Number.isInteger(value) || value < min || value > max)
191
+ throw new Error(`${label} must be an integer from ${min} to ${max}.`);
192
+ return value;
193
+ }
194
+ function cleanLabel(value) {
195
+ const label = value.trim();
196
+ if (!label || label.length > 120 || /[\u0000-\u001F\u007F-\u009F]/.test(label))
197
+ throw new Error('Alarm label must be 1-120 printable characters.');
198
+ return label;
199
+ }
200
+ function activeStatusForAgent(status) {
201
+ return {
202
+ alarmId: status.alarmId,
203
+ scheduledAt: status.scheduledAt,
204
+ stationName: status.stationName,
205
+ ...(status.station ? { station: { id: `${status.station.provider}:${status.station.id}`, name: status.station.name } } : {}),
206
+ startedAt: status.startedAt,
207
+ state: status.state,
208
+ keepPlaying: status.keepPlaying
209
+ };
210
+ }