@ciphore/radiocli 0.2.3 → 0.2.5

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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -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
  }
@@ -1,14 +1,16 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
3
  import { z } from 'zod';
4
+ import { JsonLibraryStore } from '../storage/store.js';
5
+ import { LibraryRepository } from '../storage/repository.js';
4
6
  import { appVersion } from '../version.js';
5
7
  import { receiverStyleNames, themeNames } from '../types.js';
6
8
  import { AgentRadioService, stationForAgent } from './service.js';
7
9
  export const radioCliMcpInstructions = `RadioCLI is the authoritative handler for requests about RadioCLI, “the radio”, “my radio”, recent stations, favorites, or stations from a place. Never substitute a web browser, web search, Computer Use, or another radio/music service. “Play my most recent radio station” means call radio_play with source="recent" and index=0. “Play a station from New York” means call radio_search with query="New York", then radio_play with one returned opaque station ID. Call these tools directly; do not delegate a radio-only operation or create a goal merely to monitor it.
8
10
 
9
- RadioCLI controls internet radio, alarms, and macOS AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
11
+ RadioCLI controls internet radio, alarms, and experimental AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
10
12
 
11
- For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is macOS-only. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
13
+ For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is experimental on macOS and native Linux; Linux receiver selection is required each session. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
12
14
  const alarmScheduleSchema = z.discriminatedUnion('type', [
13
15
  z.object({ type: z.literal('once'), at: z.string().min(1).describe('Absolute ISO-8601 minute including an offset or Z, with zero seconds.') }),
14
16
  z.object({
@@ -30,7 +32,9 @@ const alarmOptionsSchema = {
30
32
  keep_awake_until_alarm: z.boolean().optional()
31
33
  };
32
34
  export async function runMcpServer(runtime) {
33
- const service = new AgentRadioService(runtime);
35
+ const store = new LibraryRepository(new JsonLibraryStore());
36
+ const service = new AgentRadioService(runtime, store);
37
+ process.once('beforeExit', () => { void store.close().catch(() => undefined); });
34
38
  serveStdio(() => createMcpServer(service), { onerror: error => console.error(`RadioCLI MCP: ${error.message}`) });
35
39
  }
36
40
  function createMcpServer(service) {
@@ -39,6 +43,7 @@ function createMcpServer(service) {
39
43
  const config = schema ? { description, inputSchema: schema } : { description };
40
44
  server.registerTool(name, config, async (raw) => {
41
45
  try {
46
+ await service.refresh();
42
47
  const value = await handler(raw);
43
48
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
44
49
  }
@@ -123,7 +128,7 @@ function createMcpServer(service) {
123
128
  occurrence_at: z.string().min(1).optional(),
124
129
  snooze_minutes: z.number().int().min(1).max(1440).optional()
125
130
  }).refine(value => value.action === 'snooze' || value.snooze_minutes === undefined, { message: 'snooze_minutes is only valid for the snooze action.' }), ({ action, alarm_id, occurrence_at, snooze_minutes }) => service.alarmControl({ action, alarmId: alarm_id, occurrenceAt: occurrence_at, snoozeMinutes: snooze_minutes }));
126
- tool('radio_airplay_list', 'Discover AirPlay receivers visible to this Mac. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
131
+ tool('radio_airplay_list', 'Discover AirPlay receivers visible to this device. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
127
132
  id: device.id,
128
133
  name: device.name,
129
134
  host: device.host,
@@ -133,7 +138,7 @@ function createMcpServer(service) {
133
138
  local: device.local ?? false
134
139
  })));
135
140
  tool('radio_airplay_select', 'Switch current or future interactive playback to an exact AirPlay receiver ID returned by radio_airplay_list. Opens RadioCLI when needed unless open_ui=false.', z.object({ device_id: z.string().min(1), open_ui: z.boolean().optional() }), ({ device_id, open_ui }) => service.selectAirPlayDevice(device_id, open_ui));
136
- tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this Mac using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
141
+ tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this device using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
137
142
  tool('radio_airplay_submit_code', 'Submit a receiver-displayed AirPlay code to the active AirPlay session. Use only a code the user explicitly provides; RadioCLI never saves it.', z.object({ code: z.string().min(1).max(64) }), ({ code }) => service.submitAirPlayPasscode(code));
138
143
  return server;
139
144
  }
@@ -1,12 +1,16 @@
1
1
  import { randomInt } from 'node:crypto';
2
2
  import { defaultAgentControlSettings, themeNames, receiverStyleNames } from '../types.js';
3
+ import { LibraryRepository } from '../storage/repository.js';
3
4
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
5
+ import { parseRadioCommand } from './command-schema.js';
6
+ import { matchesStationId } from '../storage/station-identity.js';
4
7
  import { ProviderManager } from '../providers/provider-manager.js';
5
8
  import { computeListeningStats } from '../activity/stats.js';
6
9
  import { connectActiveAlarms } from '../alarms/active-session.js';
7
10
  import { connectRadioSession, ensureRadioSession } from './session.js';
8
11
  import { launchHeadlessHost, launchRadioTui } from './launcher.js';
9
12
  import { PlayerController } from '../player/player-controller.js';
13
+ import { airPlayPolicy } from '../player/airplay-policy.js';
10
14
  import { detectPlaybackBackends } from '../player/backend-install.js';
11
15
  import { AgentAlarmService } from './alarm-service.js';
12
16
  import { appVersion } from '../version.js';
@@ -15,22 +19,29 @@ export class AgentRadioService {
15
19
  runtime;
16
20
  store;
17
21
  providers;
22
+ source;
18
23
  stationCache = new Map();
19
24
  alarms;
20
- constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager()) {
25
+ constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager(),
26
+ // Local shell commands do not enable or register agent/MCP integrations.
27
+ source = 'agent') {
21
28
  this.runtime = runtime;
22
29
  this.store = store;
23
30
  this.providers = providers;
31
+ this.source = source;
24
32
  this.alarms = new AgentAlarmService(this.store, id => this.resolveStationId(id), undefined, status => this.handoffAlarm(status));
25
33
  }
26
- settings() { return this.store.snapshot().settings; }
34
+ async refresh() { if (this.store instanceof LibraryRepository)
35
+ await this.store.readFresh(); }
36
+ settings() { return this.store.settings(); }
37
+ connect() { return connectRadioSession(undefined, { source: this.source }); }
27
38
  appearance() {
28
39
  this.assertEnabled();
29
40
  return { themes: themeNames, receiverStyles: receiverStyleNames, current: this.settings() };
30
41
  }
31
42
  async status() {
32
43
  this.assertEnabled();
33
- const client = await connectRadioSession();
44
+ const client = await this.connect();
34
45
  const state = this.store.snapshot();
35
46
  return {
36
47
  connected: Boolean(client),
@@ -106,21 +117,27 @@ export class AgentRadioService {
106
117
  : queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)];
107
118
  if (!station)
108
119
  throw new Error(input.stationId ? `Unknown station ID: ${input.stationId}. Search or browse first.` : 'No station is available for that request.');
109
- const ordered = [station, ...queue.filter(item => stationKey(item) !== stationKey(station))];
120
+ return this.playStation(station, queue, input);
121
+ }
122
+ async playStation(station, queue = [station], options = {}) {
123
+ this.assertEnabled();
124
+ if ((await connectActiveAlarms()).length > 0)
125
+ throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
126
+ const ordered = queue.some(item => stationKey(item) === stationKey(station)) ? queue : [station, ...queue];
110
127
  return this.send({
111
128
  type: 'play',
112
129
  station,
113
130
  queue: ordered,
114
- openNowPlaying: this.agentSettings().focusNowPlaying,
115
- ifPlaying: input.ifPlaying ?? 'replace'
116
- }, input.openUi);
131
+ openNowPlaying: this.source === 'agent' && this.agentSettings().focusNowPlaying,
132
+ ifPlaying: options.ifPlaying ?? 'replace'
133
+ }, options.openUi);
117
134
  }
118
135
  async control(command, openUi) {
119
136
  this.assertEnabled();
120
- const client = await connectRadioSession();
137
+ const client = await this.connect();
121
138
  if (!client && command.type === 'set-volume') {
122
139
  const volume = Math.min(100, Math.max(0, command.volume));
123
- this.store.updateSettings({ volume });
140
+ await this.store.updateSettings({ volume });
124
141
  return idleResult(`Saved volume ${volume}; no playback session is active.`);
125
142
  }
126
143
  if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) {
@@ -132,17 +149,24 @@ export class AgentRadioService {
132
149
  }
133
150
  async setFavorite(favorite, stationId) {
134
151
  this.assertEnabled();
135
- const client = await connectRadioSession();
152
+ const client = await this.connect();
136
153
  if (!stationId && client)
137
154
  return client.call({ type: 'set-favorite', favorite });
138
155
  const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined;
139
156
  if (!station)
140
157
  throw new Error('No station was supplied and no active station is available.');
158
+ return this.favoriteStation(station, favorite);
159
+ }
160
+ async favoriteStation(station, favorite) {
161
+ this.assertEnabled();
162
+ const client = await this.connect();
163
+ if (client)
164
+ return client.call({ type: 'set-favorite', favorite, station });
141
165
  const isFavorite = this.store.isFavorite(station);
142
166
  if (isFavorite !== favorite)
143
- this.store.toggleFavorite(station);
167
+ await this.store.toggleFavorite(station);
144
168
  if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes)
145
- void this.providers.vote(station);
169
+ void this.providers.vote(station).catch(() => undefined);
146
170
  return { ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station };
147
171
  }
148
172
  async updateAppearance(input) {
@@ -151,10 +175,10 @@ export class AgentRadioService {
151
175
  throw new Error(`Unknown theme: ${input.theme}`);
152
176
  if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle))
153
177
  throw new Error(`Unknown receiver style: ${input.receiverStyle}`);
154
- const client = await connectRadioSession();
178
+ const client = await this.connect();
155
179
  if (client)
156
180
  return client.call({ type: 'update-settings', settings: input });
157
- return this.store.updateSettings(input).settings;
181
+ return (await this.store.updateSettings(input)).settings;
158
182
  }
159
183
  async runCompletionPreset() {
160
184
  const preset = this.agentSettings().completionPreset;
@@ -162,7 +186,7 @@ export class AgentRadioService {
162
186
  return this.play({ source: preset.source, countryCode: preset.countryCode, random: preset.source !== 'recent', openUi: preset.openUi, ifPlaying: preset.ifPlaying });
163
187
  return this.control({ type: preset.action }, preset.openUi);
164
188
  }
165
- configureCompletionPreset(input) {
189
+ async configureCompletionPreset(input) {
166
190
  this.assertEnabled();
167
191
  const agentControl = this.agentSettings();
168
192
  const completionPreset = { ...agentControl.completionPreset, ...input };
@@ -170,15 +194,15 @@ export class AgentRadioService {
170
194
  throw new Error('A two-letter countryCode is required for the country preset source.');
171
195
  if (completionPreset.countryCode)
172
196
  completionPreset.countryCode = completionPreset.countryCode.toUpperCase();
173
- return this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } }).settings.agentControl;
197
+ return (await this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } })).settings.agentControl;
174
198
  }
175
199
  async updateAgentPreferences(input) {
176
200
  this.assertEnabled();
177
201
  const agentControl = { ...this.agentSettings(), ...input };
178
- const client = await connectRadioSession();
202
+ const client = await this.connect();
179
203
  if (client)
180
204
  return client.call({ type: 'update-settings', settings: { agentControl } });
181
- return this.store.updateSettings({ agentControl }).settings.agentControl;
205
+ return (await this.store.updateSettings({ agentControl })).settings.agentControl;
182
206
  }
183
207
  alarmList() { this.assertEnabled(); return this.alarms.list(); }
184
208
  alarmStatus() { this.assertEnabled(); return this.alarms.status(); }
@@ -191,13 +215,17 @@ export class AgentRadioService {
191
215
  async listAirPlayDevices() {
192
216
  this.assertEnabled();
193
217
  this.assertAirPlayPlatform();
194
- const client = await connectRadioSession();
195
- if (client)
196
- return (await client.call({ type: 'airplay-list' })).data ?? [];
218
+ const client = await this.connect();
219
+ if (client) {
220
+ const result = await client.call({ type: 'airplay-list' });
221
+ if (!result.ok)
222
+ throw new Error(result.message);
223
+ return result.data ?? [];
224
+ }
197
225
  const player = new PlayerController(() => this.store.snapshot().settings);
198
226
  const backends = player.refreshDetectedBackends();
199
227
  if (!backends.includes('airplay'))
200
- throw new Error('AirPlay is unavailable on this Mac. Run radiocli doctor to check ffmpeg, dns-sd, and the sender package.');
228
+ throw new Error('AirPlay is unavailable on this device. Run radiocli doctor to check FFmpeg, discovery, and the optional sender.');
201
229
  return player.refreshAirPlayDevices();
202
230
  }
203
231
  async selectAirPlayDevice(deviceId, openUi) {
@@ -209,17 +237,17 @@ export class AgentRadioService {
209
237
  throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.');
210
238
  if (device.local)
211
239
  return this.useLocalOutput(openUi);
212
- this.store.updateSettings({ preferredAirPlayDevice: device.id });
240
+ await this.store.updateSettings({ preferredAirPlayDevice: device.id });
213
241
  return this.send({ type: 'airplay-select', deviceId: device.id }, openUi);
214
242
  }
215
243
  async useLocalOutput(openUi) {
216
244
  this.assertEnabled();
217
- const client = await connectRadioSession();
245
+ const client = await this.connect();
218
246
  if (!client) {
219
247
  const backend = preferredLocalBackend(detectPlaybackBackends());
220
248
  if (!backend)
221
249
  throw new Error('No local playback backend is available. Run radiocli setup to install mpv.');
222
- this.store.updateSettings({ preferredBackend: backend });
250
+ await this.store.updateSettings({ preferredBackend: backend });
223
251
  return idleResult(`Audio output set to this device (${backend}).`);
224
252
  }
225
253
  return this.send({ type: 'airplay-local' }, openUi);
@@ -227,7 +255,7 @@ export class AgentRadioService {
227
255
  async submitAirPlayPasscode(code) {
228
256
  this.assertEnabled();
229
257
  this.assertAirPlayPlatform();
230
- const client = await connectRadioSession();
258
+ const client = await this.connect();
231
259
  if (!client)
232
260
  throw new Error('No active AirPlay session is waiting for a receiver code.');
233
261
  return client.call({ type: 'airplay-passcode', code });
@@ -252,19 +280,18 @@ export class AgentRadioService {
252
280
  async send(command, openUi, allowActiveAlarm = false) {
253
281
  const settings = this.settings();
254
282
  const agentControl = settings.agentControl ?? defaultAgentControlSettings;
255
- if (!agentControl.enabled)
256
- throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
257
- let client = await connectRadioSession();
283
+ this.assertEnabled();
284
+ let client = await this.connect();
258
285
  if (!client) {
259
286
  if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0)
260
287
  throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
261
- const shouldOpen = openUi ?? agentControl.openUiOnPlay;
288
+ const shouldOpen = openUi ?? (this.source === 'agent' && agentControl.openUiOnPlay);
262
289
  client = await ensureRadioSession(async () => {
263
290
  if (shouldOpen)
264
291
  await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({ type: 'status' }));
265
292
  else
266
293
  await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath);
267
- });
294
+ }, undefined, { source: this.source });
268
295
  }
269
296
  return client.call(command);
270
297
  }
@@ -290,8 +317,10 @@ export class AgentRadioService {
290
317
  }
291
318
  async findStation(id, candidates) {
292
319
  const normalized = id.trim();
293
- const local = this.stationCache.get(normalized)
294
- ?? candidates.find(station => opaqueStationId(station) === normalized || station.id === normalized);
320
+ const matches = new Map(candidates.filter(station => matchesStationId(station, normalized)).map(station => [stationKey(station), station]));
321
+ if (matches.size > 1)
322
+ throw new Error('This legacy station ID is ambiguous. Search or browse again and use the current station ID.');
323
+ const local = this.stationCache.get(normalized) ?? [...matches.values()][0];
295
324
  if (local)
296
325
  return local;
297
326
  const separator = normalized.indexOf(':');
@@ -305,20 +334,26 @@ export class AgentRadioService {
305
334
  return station ?? undefined;
306
335
  }
307
336
  remember(stations) {
308
- for (const station of stations)
309
- this.stationCache.set(opaqueStationId(station), station);
337
+ for (const station of stations) {
338
+ const key = opaqueStationId(station);
339
+ this.stationCache.delete(key);
340
+ this.stationCache.set(key, station);
341
+ }
342
+ while (this.stationCache.size > 1024)
343
+ this.stationCache.delete(this.stationCache.keys().next().value);
310
344
  return stations;
311
345
  }
312
346
  agentSettings() {
313
347
  return this.settings().agentControl ?? defaultAgentControlSettings;
314
348
  }
315
349
  assertEnabled() {
316
- if (!this.agentSettings().enabled)
350
+ if (this.source === 'agent' && !this.agentSettings().enabled)
317
351
  throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
318
352
  }
319
353
  assertAirPlayPlatform() {
320
- if (process.platform !== 'darwin')
321
- throw new Error('AirPlay control is available only on macOS.');
354
+ const policy = airPlayPolicy(this.settings());
355
+ if (!policy.enabled)
356
+ throw new Error(policy.message);
322
357
  }
323
358
  }
324
359
  function opaqueStationId(station) { return `${station.provider}:${station.id}`; }
@@ -337,7 +372,7 @@ export function stationForAgent(station) {
337
372
  };
338
373
  }
339
374
  function encodeAgentCommand(command) { return Buffer.from(JSON.stringify(command)).toString('base64url'); }
340
- export function decodeAgentCommand(value) { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); }
375
+ export function decodeAgentCommand(value) { return parseRadioCommand(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); }
341
376
  function clampLimit(value, max = 30) { return Math.min(max, Math.max(1, Math.round(value))); }
342
377
  function idleResult(message) {
343
378
  return { ok: true, message, status: { owner: 'headless', station: null, queue: [], playback: { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false } } };