@ciphore/radiocli 0.2.3 → 0.2.4

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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,82 @@ 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
+ ## [0.2.4] - 2026-09-17
9
+
10
+ ### Added
11
+
12
+ - Added a shared platform layer for runtime eligibility, capabilities, executable
13
+ discovery, package installation, storage paths, networking, IPC, desktop
14
+ helpers, and terminal launching.
15
+ - Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and
16
+ Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including
17
+ packed installation and native mpv IPC playback checks.
18
+ - Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD,
19
+ and NetBSD guest kernels, plus explicit installation and capability guidance
20
+ for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments.
21
+ - Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited-
22
+ color, and noninteractive terminal modes with deterministic full, compact,
23
+ and micro rendering coverage.
24
+ - Added automatic HTTP/ICY metadata discovery when importing a direct station
25
+ URL from the CLI or the TUI command palette.
26
+
27
+ ### Changed
28
+
29
+ - Centralized native package recipes and guided setup so dry runs and executed
30
+ commands use the same platform-specific definitions.
31
+ - Expanded `radiocli doctor --json` with host, runtime, network, storage,
32
+ terminal, playback, desktop, scheduling, and optional-feature capabilities.
33
+ - Reorganized installation, platform, architecture, reliability, privacy, and
34
+ contributor documentation as stable reference material with explicit support
35
+ boundaries and troubleshooting paths.
36
+ - Changed TUI `:import` to add custom stream URLs, renamed full-library recovery
37
+ to `:restore`, and consolidated shell URL additions under `radiocli import`.
38
+ - Placed newest imported stations after favorites and before recently played
39
+ stations in the Library.
40
+ - Changed resume for live radio to reload the active stream at its current live
41
+ edge instead of replaying audio accumulated in mpv's paused buffer.
42
+
43
+ ### Fixed
44
+
45
+ - Kept playback and the TUI usable when optional history, presence, cache, or
46
+ library writes fail, while preserving recoverable data after interrupted or
47
+ permission-denied writes.
48
+ - Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm
49
+ Guard ownership, system-volume restoration, and forced-process teardown.
50
+ - Preserved exact arguments, data locations, and supported environment selectors
51
+ across macOS, Linux, BSD, and Windows child processes without forwarding
52
+ unrelated shell state.
53
+ - Improved player discovery, mpv readiness and IPC handling, provider timeouts,
54
+ mirror fallback, offline cache behavior, and degraded terminal rendering.
55
+ - Recovered ARM Linux playback from mpv audio-service initialization failures
56
+ by retrying once with ALSA first, retained bounded player diagnostics, and
57
+ stopped system-wide backend failures from skipping through every station.
58
+ - Replaced stale Radio Browser hosts with live DNS service discovery and added
59
+ a TLS-verified resolver fallback for temporary hostname failures without
60
+ bypassing configured proxies.
61
+ - Preserved full RGB theme colors when a terminal explicitly advertises
62
+ truecolor, even when its Node.js stream reports a conservative 256-color
63
+ depth; explicit 256-color, 16-color, and no-color modes remain unchanged.
64
+ - Kept retained Search results playable after clearing the submitted query and
65
+ added `Ctrl+F` to favorite the selected result while query input is active,
66
+ without intercepting an ordinary `f` typed into the search text.
67
+ - Dismissed added and removed favorite confirmations after 4.5 seconds on
68
+ Overview, Now Playing, Library, Explore, Search, Nearby, and country station
69
+ views.
70
+ - Marked AirPlay controls as macOS-only, hid the unsupported Settings shortcut,
71
+ and kept non-macOS users on their current screen with a direct platform notice.
72
+ - Corrected Windows terminal launches to create independent interactive console
73
+ handles and retain the intended user data location.
74
+
75
+ ### Security
76
+
77
+ - Updated documentation-site dependencies to patched releases.
78
+ - Limited child-process environments to the settings required for RadioCLI data
79
+ identity and native integrations, excluding unrelated credentials and proxy
80
+ secrets.
81
+ - Made native cleanup and ownership checks fail closed when a job, helper,
82
+ process, or saved state cannot be identified safely.
83
+
8
84
  ## [0.2.3] - 2026-09-07
9
85
 
10
86
  ### Added
@@ -397,6 +473,7 @@ Initial public release.
397
473
  [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1
398
474
  [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2
399
475
  [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3
476
+ [0.2.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.4
400
477
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
401
478
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
402
479
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/CONTRIBUTING.md CHANGED
@@ -12,19 +12,22 @@ npm run build
12
12
  npm run test
13
13
  ```
14
14
 
15
- Install `mpv` for playback controls, metadata polling, and the best playback
16
- experience:
15
+ Install `mpv` for playback controls and metadata polling. On a macOS
16
+ configuration supported by Homebrew:
17
17
 
18
18
  ```bash
19
19
  brew install mpv
20
20
  ```
21
21
 
22
- On Linux, use your package manager's `mpv` package. On Windows, use:
22
+ On Linux, use your package manager's `mpv` package. On Windows x64, use:
23
23
 
24
24
  ```powershell
25
25
  winget install --id shinchiro.mpv -e
26
26
  ```
27
27
 
28
+ Use the [platform installation routes](apps/docs/content/docs/platforms.mdx#installation-and-playback-prerequisites)
29
+ for Windows arm64 and other package managers.
30
+
28
31
  ## Verification
29
32
 
30
33
  Run these before opening a PR:
@@ -34,11 +37,19 @@ npm run check
34
37
  npm run lint
35
38
  npm run test
36
39
  npm run build
40
+ npm run pack:check
41
+ npm run fresh:check -- --require-mpv
42
+ npm run check:package
43
+ npm run smoke:mcp
44
+ npm run docs:check
45
+ npm run docs:build
37
46
  ```
38
47
 
39
- Run `npm run smoke:playback` when changing playback code. It opens a public
40
- stream briefly, so do not run it in environments where audio/network access is
41
- not acceptable.
48
+ Install documentation dependencies with `npm --prefix apps/docs ci` first.
49
+ Run `npm run smoke:playback` when changing playback code. Its default fixture is
50
+ a local WAV file; mpv uses null audio in deterministic package
51
+ checks. These checks exercise the player process and controls, and do not prove
52
+ speaker output. `npm run smoke:data` intentionally contacts public providers.
42
53
 
43
54
  Live provider checks are useful before releases, but ordinary pull requests
44
55
  should stay deterministic. Run `npm run verify:release` for a full maintainer
@@ -52,9 +63,28 @@ checks.
52
63
  - Never make location lookup implicit.
53
64
  - Prefer clear terminal text over decorative filler.
54
65
  - Keep command-line and TUI behavior aligned.
66
+ - Put OS decisions in `src/platform`; keep capability availability separate from
67
+ runtime eligibility and recorded platform verification.
55
68
  - Add tests for parsing, storage, layout, or provider transformations when the
56
69
  behavior can be checked without live audio.
57
70
 
71
+ ## Platform verification
72
+
73
+ The required Node 22/24 CI matrix tests x64 and arm64 installations and real mpv
74
+ control. Weekly or manually triggered BSD checks execute the packed JavaScript
75
+ in guest kernels. Changes to script harnesses
76
+ also require `node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs`.
77
+
78
+ Record OS release, CPU, endianness, libc, Node/npm versions, artifact hash, and
79
+ whether execution was native, virtualized, emulated, or mocked. An installation
80
+ test, native command mock, or foreign binary inspection alone cannot promote a
81
+ platform to supported. Follow the [support tiers](apps/docs/content/docs/platforms.mdx).
82
+
83
+ The deterministic visual fixtures cover full, compact, and micro layouts, all
84
+ major screens, and receiver styles. Preserve Unicode captures. When rendering
85
+ changes intentionally, describe the visible effect in the pull request and do
86
+ not update snapshots merely to hide failures.
87
+
58
88
  ## Issue Triage
59
89
 
60
90
  Playback issues should include station name, country, backend, and
package/README.md CHANGED
@@ -26,7 +26,7 @@ Built with [Ink](https://github.com/vadimdemedes/ink),
26
26
 
27
27
  ## Quick start
28
28
 
29
- macOS with Homebrew:
29
+ On macOS configurations supported by Homebrew:
30
30
 
31
31
  ```bash
32
32
  brew install ciphore/tap/radiocli
@@ -44,11 +44,43 @@ radiocli
44
44
  RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and
45
45
  FFmpeg. After an npm install, `radiocli setup` detects the operating system and
46
46
  package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected
47
- native tools with branded progress feedback, and verifies playback readiness.
47
+ native tools with branded progress feedback, and checks that executables are
48
+ available. Play a station to verify the actual audio path. Intel or older macOS
49
+ installations should consult the platform matrix for alternative package routes.
48
50
 
49
51
  See the [installation guide](apps/docs/content/docs/getting-started/install.mdx)
50
52
  for Windows, Linux distributions, AirPlay prerequisites, and fallback players.
51
53
 
54
+ ## Platform coverage
55
+
56
+ RadioCLI separates core playback, storage, terminal rendering, desktop helpers,
57
+ and native alarm services. A missing scheduler or clipboard helper leaves the
58
+ other features available. `radiocli doctor --json` reports each capability,
59
+ terminal and network policy, and runtime eligibility separately.
60
+
61
+ The [platform matrix](apps/docs/content/docs/platforms.mdx) distinguishes required
62
+ CI targets from verified installations, experimental community paths, and known
63
+ runtime blockers. Node 22 and 24 are the reference test lines. BSD package plans,
64
+ Termux, Haiku and illumos guidance do not imply that every OS/CPU combination has
65
+ been exercised. AIX playback and Haiku's packaged Node 20 remain explicit gaps.
66
+ Linux without a usable systemd user session can still run the player and TUI;
67
+ reliable background alarms require a verified native adapter.
68
+
69
+ Invocation preferences do not change saved settings:
70
+
71
+ ```bash
72
+ RADIOCLI_ASCII=1 radiocli # ASCII decoration; station names preserved
73
+ NO_COLOR=1 radiocli # no color or background fills
74
+ RADIOCLI_SCREEN_READER=1 radiocli # readable controls; no visualizer animation
75
+ RADIOCLI_OFFLINE=1 radiocli # cached directories and saved stations
76
+ RADIOCLI_LOW_BANDWIDTH=1 radiocli # cached atlas; no automatic receiver scan
77
+ ```
78
+
79
+ Offline mode disables directory, location, update, vote and receiver-discovery
80
+ requests. An external player still needs a connection to listen to a live stream.
81
+ For proxy, SSH, read-only storage and custom-player paths, see
82
+ [troubleshooting](apps/docs/content/docs/troubleshooting.mdx).
83
+
52
84
  ## Visual tour
53
85
 
54
86
  These recordings come from the built TUI. Generate them locally with
@@ -81,10 +113,10 @@ These recordings come from the built TUI. Generate them locally with
81
113
  | `←` / `→` or `Tab` / `Shift+Tab` | Switch screens |
82
114
  | `↑` / `↓` or `n` / `p` | Move the selection |
83
115
  | `Enter` | Open or tune the selection |
84
- | `space` or `F8` | Pause or resume with `mpv` |
116
+ | `space` or `F8` | Pause; resume reconnects at the live edge with `mpv` |
85
117
  | `,` / `.` or `F7` / `F9` | Previous or next station |
86
118
  | `+` / `-` | Change volume |
87
- | `f` | Save or remove a favorite |
119
+ | `f` (`Ctrl+F` while typing in Search) | Save or remove a favorite |
88
120
  | `?` | Open all shortcuts and commands |
89
121
  | `q` or `Ctrl+C` twice | Quit cleanly |
90
122
 
@@ -118,9 +150,9 @@ radiocli check # Check providers, playback tools, and the local store
118
150
  radiocli doctor --json # Create a redacted support report
119
151
  radiocli search "japan hits"
120
152
  radiocli countries
153
+ radiocli import https://example.com/live.mp3
121
154
  radiocli import stations.m3u
122
155
  radiocli export favorites.m3u
123
- radiocli add-url <stream-url> [station name]
124
156
  radiocli alarm list
125
157
  radiocli alarm doctor
126
158
  radiocli update --install # Upgrade and repair enabled MCP registrations
@@ -137,7 +169,15 @@ fully quit and reopen agent clients after enabling, repairing, or upgrading.
137
169
  Until that restart, even a newly created task in an already-running client will
138
170
  not have RadioCLI's tools and may incorrectly fall back to browser playback.
139
171
 
140
- RadioCLI imports `.m3u`, `.pls`, and `.xspf` playlists. It exports favorites
172
+ RadioCLI imports direct HTTP(S) station streams as well as `.m3u`, `.pls`, and
173
+ `.xspf` playlists. Direct import is useful when a station cannot be found through
174
+ an open radio directory—for example, an iHeartMedia station that is available
175
+ only through its direct stream. Find the station's direct stream URL and run
176
+ `radiocli import <stream-url> [station name]`, or enter
177
+ `:import <stream-url> [station name]` in the TUI. RadioCLI reads available
178
+ HTTP/ICY metadata to discover the name, codec, bitrate, and homepage; the name
179
+ argument is optional and overrides the published name. Imported stations appear
180
+ after favorites and before recently played stations. RadioCLI exports favorites
141
181
  and imported streams as `.m3u`.
142
182
 
143
183
  Alarms are experimental beta functionality and live under **Overview**,
@@ -214,6 +254,8 @@ complete data-flow description.
214
254
  - `src/ui` — screens, input, layout, and terminal rendering
215
255
  - `src/providers` — station directories, resolution, and caches
216
256
  - `src/player` — playback backends, metadata, and AirPlay
257
+ - `src/platform` — host identity, independent capabilities, native command plans,
258
+ paths, terminal policy, networking and runtime eligibility
217
259
  - `src/alarms` — schedules, native registration, Alarm Guard, and active controls
218
260
  - `src/storage` — local library persistence and migration
219
261
  - `apps/docs` — documentation website and manual
@@ -231,9 +273,10 @@ npm run verify
231
273
  npm run dev
232
274
  ```
233
275
 
234
- `npm run verify` checks types, lint, tests, the production build, and package
235
- contents. Playback and live-data smoke tests are available separately because
236
- they contact public services or start a local player.
276
+ `npm run verify` checks types, lint, tests, the production build, and live provider
277
+ data. `npm run fresh:check -- --require-mpv` tests packed installs with and without
278
+ optional dependencies using a local WAV, real mpv IPC controls and MCP. See
279
+ [contribution checks](CONTRIBUTING.md) for documentation and packaging validation.
237
280
 
238
281
  ## Contributing
239
282
 
@@ -244,9 +287,10 @@ include `radiocli check` output with playback reports.
244
287
 
245
288
  - [Getting started](apps/docs/content/docs/index.mdx)
246
289
  - [Installation](apps/docs/content/docs/getting-started/install.mdx)
290
+ - [Platform matrix](apps/docs/content/docs/platforms.mdx)
291
+ - [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx)
247
292
  - [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx)
248
293
  - [Architecture](apps/docs/content/docs/architecture.mdx)
249
- - [Roadmap](apps/docs/content/docs/roadmap.mdx)
250
294
  - [Release packaging](apps/docs/content/docs/release-packaging.mdx)
251
295
 
252
296
  Run the documentation site locally with `npm run docs:dev`.
@@ -2,7 +2,7 @@ import { PlayerController } from '../player/player-controller.js';
2
2
  import { ProviderManager } from '../providers/provider-manager.js';
3
3
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
4
  import { startRadioSession } from './session.js';
5
- import { detectPlaybackBackends } from '../player/backend-install.js';
5
+ import { airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported } from '../player/backend-install.js';
6
6
  export async function runHeadlessAgentHost() {
7
7
  const store = new JsonLibraryStore();
8
8
  const providers = new ProviderManager();
@@ -10,23 +10,43 @@ export async function runHeadlessAgentHost() {
10
10
  player.refreshDetectedBackends();
11
11
  let station = null;
12
12
  let queue = [];
13
- const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, output: {
13
+ let persistenceWarning;
14
+ const persistHistory = (write) => {
15
+ try {
16
+ write();
17
+ }
18
+ catch (error) {
19
+ const code = error?.code;
20
+ const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
21
+ persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`;
22
+ }
23
+ };
24
+ const saveLibrary = (write) => {
25
+ write();
26
+ // Optional writes can be no-ops, so only an explicit successful save clears
27
+ // a prior warning. Explicit failures still reject the control request.
28
+ persistenceWarning = undefined;
29
+ };
30
+ const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: {
14
31
  preferredBackend: store.snapshot().settings.preferredBackend,
15
32
  preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
16
33
  } });
17
34
  const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
18
35
  const play = async (next, nextQueue = [next]) => {
19
- store.finishActiveListeningSession();
36
+ persistHistory(() => store.finishActiveListeningSession());
20
37
  await player.stop();
21
38
  const resolved = await providers.resolve(next);
22
39
  await player.play(next, resolved.url);
23
40
  station = next;
24
41
  queue = nextQueue.length ? nextQueue : [next];
25
- store.addRecent(next);
26
- store.startListeningSession(next);
42
+ persistHistory(() => store.addRecent(next));
43
+ persistHistory(() => store.startListeningSession(next));
27
44
  return result(`Playing ${next.name}.`);
28
45
  };
29
46
  const handle = async (command) => {
47
+ if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !isAirPlayPlatformSupported()) {
48
+ return result(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined);
49
+ }
30
50
  if (command.type === 'status')
31
51
  return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
32
52
  if (command.type === 'play') {
@@ -37,17 +57,17 @@ export async function runHeadlessAgentHost() {
37
57
  if (command.type === 'pause') {
38
58
  const control = await player.pause();
39
59
  if (control.ok)
40
- store.finishActiveListeningSession();
60
+ persistHistory(() => store.finishActiveListeningSession());
41
61
  return result(control.message ?? 'Paused.', control.ok);
42
62
  }
43
63
  if (command.type === 'resume') {
44
64
  const control = await player.resume();
45
65
  if (control.ok && station)
46
- store.startListeningSession(station);
66
+ persistHistory(() => store.startListeningSession(station));
47
67
  return result(control.message ?? 'Resumed.', control.ok);
48
68
  }
49
69
  if (command.type === 'stop') {
50
- store.finishActiveListeningSession();
70
+ persistHistory(() => store.finishActiveListeningSession());
51
71
  await player.stop();
52
72
  station = null;
53
73
  queue = [];
@@ -55,7 +75,7 @@ export async function runHeadlessAgentHost() {
55
75
  return result('Stopped RadioCLI.');
56
76
  }
57
77
  if (command.type === 'alarm-preempt') {
58
- store.finishActiveListeningSession();
78
+ persistHistory(() => store.finishActiveListeningSession());
59
79
  await player.stop();
60
80
  station = null;
61
81
  queue = [];
@@ -64,7 +84,7 @@ export async function runHeadlessAgentHost() {
64
84
  if (command.type === 'set-volume') {
65
85
  const control = await player.setVolume(command.volume);
66
86
  if (control.ok)
67
- store.updateSettings({ volume: player.getState().volume });
87
+ saveLibrary(() => store.updateSettings({ volume: player.getState().volume }));
68
88
  return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
69
89
  }
70
90
  if (command.type === 'set-muted') {
@@ -77,7 +97,7 @@ export async function runHeadlessAgentHost() {
77
97
  return result('No active station to favorite.', false);
78
98
  const current = store.isFavorite(target);
79
99
  if (current !== command.favorite)
80
- store.toggleFavorite(target);
100
+ saveLibrary(() => store.toggleFavorite(target));
81
101
  if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
82
102
  void providers.vote(target);
83
103
  return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
@@ -95,8 +115,10 @@ export async function runHeadlessAgentHost() {
95
115
  return switchToLocal();
96
116
  if (!detectPlaybackBackends().includes('airplay'))
97
117
  return result('AirPlay playback is unavailable; run radiocli doctor.', false);
98
- store.updateSettings({ preferredAirPlayDevice: device.id });
99
- store.updateSettings({ preferredBackend: 'airplay' });
118
+ saveLibrary(() => {
119
+ store.updateSettings({ preferredAirPlayDevice: device.id });
120
+ store.updateSettings({ preferredBackend: 'airplay' });
121
+ });
100
122
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
101
123
  return play(station, queue);
102
124
  return result(`Audio output set to AirPlay receiver ${device.name}.`);
@@ -108,7 +130,7 @@ export async function runHeadlessAgentHost() {
108
130
  return result(control.message ?? 'AirPlay code sent.', control.ok);
109
131
  }
110
132
  if (command.type === 'update-settings') {
111
- store.updateSettings(command.settings);
133
+ saveLibrary(() => store.updateSettings(command.settings));
112
134
  return result('RadioCLI settings updated.');
113
135
  }
114
136
  const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
@@ -121,7 +143,7 @@ export async function runHeadlessAgentHost() {
121
143
  const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
122
144
  if (!backend)
123
145
  return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
124
- store.updateSettings({ preferredBackend: backend });
146
+ saveLibrary(() => store.updateSettings({ preferredBackend: backend }));
125
147
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
126
148
  return play(station, queue);
127
149
  return result(`Audio output set to this device (${backend}).`);
@@ -129,11 +151,11 @@ export async function runHeadlessAgentHost() {
129
151
  const session = await startRadioSession(handle);
130
152
  const checkpoint = setInterval(() => {
131
153
  if (player.getState().state === 'playing')
132
- store.checkpointActiveListeningSession();
154
+ persistHistory(() => store.checkpointActiveListeningSession());
133
155
  }, 30_000);
134
156
  const shutdown = async () => {
135
157
  clearInterval(checkpoint);
136
- store.finishActiveListeningSession();
158
+ persistHistory(() => store.finishActiveListeningSession());
137
159
  await player.stop();
138
160
  await session.close();
139
161
  };
@@ -1,71 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
4
- import { detectAlarmTerminal } from '../alarms/terminal-launcher.js';
5
- const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']);
6
- export async function launchRadioTui(nodePath, cliPath, encodedCommand) {
7
- const terminal = detectAlarmTerminal();
8
- const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]);
9
- const command = `${direct.map(shellQuote).join(' ')}`;
10
- if (terminal === 'darwin:apple-terminal')
11
- await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command)));
12
- else if (terminal === 'darwin:iterm')
13
- await launched(spawnDetached('/usr/bin/osascript', iTermScript(command)));
14
- else if (terminal === 'darwin:wezterm')
15
- await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
16
- else if (terminal === 'darwin:ghostty')
17
- await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
18
- else if (terminal === 'darwin:kitty')
19
- await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
20
- else if (terminal === 'win32:windows-terminal')
21
- await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct]));
22
- else if (terminal === 'win32:console')
23
- await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)]));
24
- else if (terminal.startsWith('linux:')) {
25
- const executable = terminal.slice('linux:'.length);
26
- if (!linuxTerminals.has(basename(executable)))
27
- throw new Error('Saved Linux terminal is not supported.');
28
- const name = basename(executable);
29
- const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal'
30
- ? ['--']
31
- : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
32
- await launched(spawnDetached(executable, [...prefix, ...direct]));
33
- }
34
- else {
35
- throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.');
36
- }
2
+ import { launchTerminalCommand } from '../platform/terminals.js';
3
+ import { waitForLaunch } from '../platform/launch-command.js';
4
+ /** The agent service verifies the authenticated session after this request. */
5
+ export async function launchRadioTui(nodePath, cliPath, encodedCommand, options = {}) {
6
+ await launchTerminalCommand({ ...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI' });
37
7
  }
38
8
  export async function launchHeadlessHost(nodePath, cliPath) {
39
- await launched(spawnDetached(nodePath, [cliPath, 'agent-host']));
40
- }
41
- function environmentCommand(nodePath, cliPath, args) {
42
- const command = [nodePath, cliPath, ...args];
43
- const radioCliHome = process.env.RADIOCLI_HOME;
44
- if (!radioCliHome)
45
- return command;
46
- if (process.platform === 'win32') {
47
- return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`];
48
- }
49
- return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command];
50
- }
51
- function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
52
- function windowsCommand(values) { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); }
53
- function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
54
- function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
55
- function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
56
- function spawnDetached(command, args) { return spawn(command, [...args], { detached: true, stdio: 'ignore', windowsHide: false }); }
57
- function launched(child) { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); }
58
- export function resolveExecutable(input, env = process.env, platform = process.platform) {
59
- if ((input.includes('/') || input.includes('\\')) && existsSync(input))
60
- return input;
61
- const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
62
- const pathDelimiter = platform === 'win32' ? ';' : ':';
63
- for (const directory of (env.PATH ?? '').split(pathDelimiter)) {
64
- for (const suffix of suffixes) {
65
- const path = join(directory, `${input}${suffix}`);
66
- if (existsSync(path))
67
- return path;
68
- }
69
- }
70
- return undefined;
9
+ await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], { detached: true, stdio: 'ignore', windowsHide: false }));
71
10
  }
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { applyEdits, modify, parse } from 'jsonc-parser';
6
6
  import { JsonLibraryStore } from '../storage/store.js';
7
7
  import { defaultAgentControlSettings } from '../types.js';
8
- import { resolveExecutable } from './launcher.js';
8
+ import { resolveCommandDetails } from '../platform/executables.js';
9
9
  const formatting = { insertSpaces: true, tabSize: 2, eol: '\n' };
10
10
  export async function configureMcpIntegrations(enabled, runtime, output = process.stdout) {
11
11
  const store = new JsonLibraryStore();
@@ -31,7 +31,7 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
31
31
  }
32
32
  else
33
33
  results.push(notFound('Codex'));
34
- const claude = resolveExecutable('claude');
34
+ const claude = resolveCommandDetails('claude', { env: process.env }).path;
35
35
  if (claude) {
36
36
  results.push(await configureCliClient('Claude Code', claude, enabled
37
37
  ? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command]
@@ -40,7 +40,7 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
40
40
  else
41
41
  results.push(notFound('Claude Code'));
42
42
  const openCodePath = openCodeConfigPath();
43
- if (resolveExecutable('opencode') || existsSync(openCodePath)) {
43
+ if (resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)) {
44
44
  try {
45
45
  updateOpenCodeConfig(openCodePath, enabled, command);
46
46
  results.push({ client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath });
@@ -54,13 +54,13 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
54
54
  configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, {
55
55
  command: command[0], args: command.slice(1)
56
56
  });
57
- configureJsonClient(results, 'Gemini CLI', Boolean(resolveExecutable('gemini')), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, {
57
+ configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', { env: process.env }).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, {
58
58
  command: command[0], args: command.slice(1)
59
59
  });
60
60
  configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, {
61
61
  type: 'stdio', command: command[0], args: command.slice(1)
62
62
  });
63
- if (resolveExecutable('orca')) {
63
+ if (resolveCommandDetails('orca', { env: process.env }).path) {
64
64
  results.push({ client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.' });
65
65
  }
66
66
  else
@@ -182,7 +182,7 @@ export function probeMcpServer(command, timeoutMs = 5_000) {
182
182
  });
183
183
  }
184
184
  function matchingRadioCliShim(runtime, platform) {
185
- const candidate = resolveExecutable('radiocli', process.env, platform);
185
+ const candidate = resolveCommandDetails('radiocli', { env: process.env, platform }).path;
186
186
  if (!candidate)
187
187
  return undefined;
188
188
  if (!usableDirectLauncher(candidate, platform))
@@ -204,16 +204,16 @@ async function mcpClientStates(command) {
204
204
  const vscodePath = join(homedir(), '.copilot', 'mcp-config.json');
205
205
  return {
206
206
  codex: await codexClientState(resolveCodexExecutable(), command),
207
- claude: await claudeClientState(resolveExecutable('claude'), command),
208
- opencode: jsonClientState(Boolean(resolveExecutable('opencode') || existsSync(openCodePath)), openCodePath, [
207
+ claude: await claudeClientState(resolveCommandDetails('claude', { env: process.env }).path, command),
208
+ opencode: jsonClientState(Boolean(resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)), openCodePath, [
209
209
  ['mcp', 'servers', 'radiocli'], ['mcp', 'radiocli']
210
210
  ], command),
211
211
  cursor: jsonClientState(cursorDetected(), cursorPath, [['mcpServers', 'radiocli']], command),
212
- gemini: jsonClientState(Boolean(resolveExecutable('gemini')), geminiPath, [['mcpServers', 'radiocli']], command),
212
+ gemini: jsonClientState(Boolean(resolveCommandDetails('gemini', { env: process.env }).path), geminiPath, [['mcpServers', 'radiocli']], command),
213
213
  vscode: jsonClientState(vsCodeDetected(), vscodePath, [['servers', 'radiocli']], command),
214
214
  orca: {
215
- detected: Boolean(resolveExecutable('orca')),
216
- state: resolveExecutable('orca') ? 'managed-by-client' : 'not-found',
215
+ detected: Boolean(resolveCommandDetails('orca', { env: process.env }).path),
216
+ state: resolveCommandDetails('orca', { env: process.env }).path ? 'managed-by-client' : 'not-found',
217
217
  detail: 'inherits MCP configuration from its Codex or Claude runtime'
218
218
  },
219
219
  portable: jsonClientState(true, portableConfigPath(), [['mcpServers', 'radiocli']], command)
@@ -226,7 +226,7 @@ export function resolveCodexExecutable(env = process.env, platform = process.pla
226
226
  const configured = env.CODEX_CLI_PATH;
227
227
  if (configured && existsSync(configured))
228
228
  return configured;
229
- const pathExecutable = resolveExecutable('codex', env, platform);
229
+ const pathExecutable = resolveCommandDetails('codex', { env, platform, home }).path;
230
230
  if (pathExecutable)
231
231
  return pathExecutable;
232
232
  const candidates = platform === 'darwin'
@@ -243,14 +243,14 @@ export function resolveCodexExecutable(env = process.env, platform = process.pla
243
243
  return candidates.find(candidate => existsSync(candidate));
244
244
  }
245
245
  function cursorDetected() {
246
- return Boolean(resolveExecutable('cursor') ||
247
- resolveExecutable('cursor-agent') ||
246
+ return Boolean(resolveCommandDetails('cursor', { env: process.env }).path ||
247
+ resolveCommandDetails('cursor-agent', { env: process.env }).path ||
248
248
  existsSync(join(homedir(), '.cursor')) ||
249
249
  (process.platform === 'darwin' && existsSync('/Applications/Cursor.app')) ||
250
250
  (process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'cursor', 'Cursor.exe'))));
251
251
  }
252
252
  function vsCodeDetected() {
253
- return Boolean(resolveExecutable('code') ||
253
+ return Boolean(resolveCommandDetails('code', { env: process.env }).path ||
254
254
  (process.platform === 'darwin' && existsSync('/Applications/Visual Studio Code.app')) ||
255
255
  (process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'Microsoft VS Code', 'Code.exe'))));
256
256
  }
@@ -7,7 +7,7 @@ import { connectActiveAlarms } from '../alarms/active-session.js';
7
7
  import { connectRadioSession, ensureRadioSession } from './session.js';
8
8
  import { launchHeadlessHost, launchRadioTui } from './launcher.js';
9
9
  import { PlayerController } from '../player/player-controller.js';
10
- import { detectPlaybackBackends } from '../player/backend-install.js';
10
+ import { airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported } from '../player/backend-install.js';
11
11
  import { AgentAlarmService } from './alarm-service.js';
12
12
  import { appVersion } from '../version.js';
13
13
  import { checkForUpdate, updateCommandForInstall } from '../update-check.js';
@@ -317,8 +317,8 @@ export class AgentRadioService {
317
317
  throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
318
318
  }
319
319
  assertAirPlayPlatform() {
320
- if (process.platform !== 'darwin')
321
- throw new Error('AirPlay control is available only on macOS.');
320
+ if (!isAirPlayPlatformSupported())
321
+ throw new Error(airPlayMacOSOnlyMessage);
322
322
  }
323
323
  }
324
324
  function opaqueStationId(station) { return `${station.provider}:${station.id}`; }