@ciphore/radiocli 0.2.2 → 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 (40) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +30 -0
  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/cli.js +4 -1
  12. package/dist/alarms/runner.js +43 -26
  13. package/dist/cli.js +60 -3
  14. package/dist/player/player-controller.js +19 -0
  15. package/dist/providers/provider-manager.js +5 -0
  16. package/dist/providers/radio-browser.js +4 -0
  17. package/dist/setup.js +71 -2
  18. package/dist/storage/store.js +33 -1
  19. package/dist/types.js +6 -0
  20. package/dist/ui/AdaptiveContent.js +24 -9
  21. package/dist/ui/App.js +299 -18
  22. package/dist/ui/AppContent.js +4 -4
  23. package/dist/ui/components/StationList.js +3 -5
  24. package/dist/ui/components/VersionIndicator.js +19 -0
  25. package/dist/ui/page-footer.js +4 -2
  26. package/dist/ui/screen-items.js +40 -9
  27. package/dist/ui/screens/AlarmsScreen.js +2 -1
  28. package/dist/ui/screens/CountriesScreen.js +8 -5
  29. package/dist/ui/screens/HomeScreen.js +3 -1
  30. package/dist/ui/screens/SettingsScreen.js +70 -53
  31. package/dist/ui/use-alarm-tui.js +4 -0
  32. package/dist/ui/use-app-input.js +29 -3
  33. package/dist/ui/visualizers/gallop.js +118 -0
  34. package/dist/ui/visualizers/horse-stride.js +20 -0
  35. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  36. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  37. package/dist/ui/visualizers/retro-receivers.js +4 -0
  38. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  39. package/dist/update-check.js +26 -7
  40. package/package.json +4 -1
@@ -0,0 +1,248 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createServer, request } from 'node:http';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ export async function startRadioSession(handle, filePath = radioSessionPath()) {
7
+ const ownerPath = `${filePath}.owner`;
8
+ acquireOwner(ownerPath);
9
+ const token = randomBytes(32).toString('hex');
10
+ let serial = Promise.resolve();
11
+ const server = createServer((req, res) => {
12
+ res.setHeader('content-type', 'application/json');
13
+ if (req.headers.authorization !== `Bearer ${token}`) {
14
+ res.statusCode = 401;
15
+ res.end('{}');
16
+ return;
17
+ }
18
+ if (req.method !== 'POST' || req.url !== '/command') {
19
+ res.statusCode = 404;
20
+ res.end('{}');
21
+ return;
22
+ }
23
+ const work = serial.then(async () => {
24
+ const body = await readBody(req);
25
+ return handle(JSON.parse(body));
26
+ });
27
+ serial = work.then(() => undefined, () => undefined);
28
+ void work.then(result => res.end(JSON.stringify(result))).catch(error => {
29
+ res.statusCode = 400;
30
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Radio control failed.' }));
31
+ });
32
+ });
33
+ try {
34
+ await new Promise((resolve, reject) => {
35
+ server.once('error', reject);
36
+ server.listen(0, '127.0.0.1', () => resolve());
37
+ });
38
+ const address = server.address();
39
+ if (!address || typeof address === 'string')
40
+ throw new Error('Unable to create RadioCLI control endpoint.');
41
+ const discovery = {
42
+ version: 1,
43
+ host: '127.0.0.1',
44
+ port: address.port,
45
+ token,
46
+ pid: process.pid,
47
+ createdAt: new Date().toISOString()
48
+ };
49
+ writePrivateJson(filePath, discovery);
50
+ return {
51
+ async close() {
52
+ await new Promise(resolve => server.close(() => resolve()));
53
+ removeIfOwned(filePath, discovery);
54
+ removeOwner(ownerPath, process.pid);
55
+ }
56
+ };
57
+ }
58
+ catch (error) {
59
+ removeOwner(ownerPath, process.pid);
60
+ await new Promise(resolve => server.close(() => resolve()));
61
+ throw error;
62
+ }
63
+ }
64
+ export async function connectRadioSession(filePath = radioSessionPath()) {
65
+ if (!existsSync(filePath))
66
+ return null;
67
+ let discovery;
68
+ try {
69
+ discovery = JSON.parse(readFileSync(filePath, 'utf8'));
70
+ if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) {
71
+ throw new Error('invalid');
72
+ }
73
+ }
74
+ catch {
75
+ rmSync(filePath, { force: true });
76
+ return null;
77
+ }
78
+ const call = async (command) => {
79
+ const payload = JSON.stringify(command);
80
+ return new Promise((resolve, reject) => {
81
+ const req = request({
82
+ host: '127.0.0.1',
83
+ port: discovery.port,
84
+ path: '/command',
85
+ method: 'POST',
86
+ headers: {
87
+ authorization: `Bearer ${discovery.token}`,
88
+ 'content-type': 'application/json',
89
+ 'content-length': Buffer.byteLength(payload)
90
+ }
91
+ }, res => {
92
+ let text = '';
93
+ res.on('data', value => { text += String(value); });
94
+ res.on('end', () => {
95
+ if (res.statusCode && res.statusCode < 300)
96
+ resolve(JSON.parse(text));
97
+ else
98
+ reject(new Error(parseError(text)));
99
+ });
100
+ });
101
+ req.once('error', reject);
102
+ req.setTimeout(15_000, () => req.destroy(new Error('RadioCLI control request timed out.')));
103
+ req.end(payload);
104
+ });
105
+ };
106
+ try {
107
+ await call({ type: 'status' });
108
+ return { call, status: async () => (await call({ type: 'status' })).status };
109
+ }
110
+ catch {
111
+ if (!processAlive(discovery.pid) || discoveryAgeMs(filePath, discovery) > 60_000) {
112
+ removeIfOwned(filePath, discovery);
113
+ removeOwner(`${filePath}.owner`, discovery.pid);
114
+ }
115
+ return null;
116
+ }
117
+ }
118
+ export async function ensureRadioSession(start, timeoutMs = 15_000) {
119
+ const existing = await connectRadioSession();
120
+ if (existing)
121
+ return existing;
122
+ const lockPath = `${radioSessionPath()}.launch`;
123
+ let ownsLaunch = false;
124
+ try {
125
+ acquireOwner(lockPath);
126
+ ownsLaunch = true;
127
+ const appeared = await connectRadioSession();
128
+ if (appeared)
129
+ return appeared;
130
+ await start();
131
+ return await waitForSession(timeoutMs);
132
+ }
133
+ catch (error) {
134
+ if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active'))
135
+ throw error;
136
+ return waitForSession(timeoutMs);
137
+ }
138
+ finally {
139
+ if (ownsLaunch)
140
+ removeOwner(lockPath, process.pid);
141
+ }
142
+ }
143
+ function radioSessionPath() {
144
+ return join(runtimeDirectory(), 'agent-session.json');
145
+ }
146
+ async function waitForSession(timeoutMs) {
147
+ const deadline = Date.now() + timeoutMs;
148
+ while (Date.now() < deadline) {
149
+ const client = await connectRadioSession();
150
+ if (client)
151
+ return client;
152
+ await new Promise(resolve => setTimeout(resolve, 100));
153
+ }
154
+ throw new Error('RadioCLI was launched but its control session did not become ready.');
155
+ }
156
+ function runtimeDirectory() {
157
+ if (process.env.RADIOCLI_HOME)
158
+ return join(process.env.RADIOCLI_HOME, 'runtime');
159
+ if (process.platform === 'win32')
160
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime');
161
+ return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli');
162
+ }
163
+ function acquireOwner(path) {
164
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
165
+ for (let attempt = 0; attempt < 2; attempt += 1) {
166
+ try {
167
+ const fd = openSync(path, 'wx', 0o600);
168
+ writeFileSync(fd, `${process.pid}\n`);
169
+ closeSync(fd);
170
+ return;
171
+ }
172
+ catch (error) {
173
+ if (error.code !== 'EEXIST')
174
+ throw error;
175
+ const pid = Number(readFileSync(path, 'utf8').trim());
176
+ if (processAlive(pid))
177
+ throw new Error('Another RadioCLI playback session is already active.');
178
+ rmSync(path, { force: true });
179
+ }
180
+ }
181
+ throw new Error('Could not claim the RadioCLI playback session.');
182
+ }
183
+ function removeOwner(path, pid) {
184
+ try {
185
+ if (Number(readFileSync(path, 'utf8').trim()) === pid)
186
+ rmSync(path, { force: true });
187
+ }
188
+ catch { }
189
+ }
190
+ function writePrivateJson(path, value) {
191
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
192
+ const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
193
+ writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
194
+ renameSync(temp, path);
195
+ if (process.platform !== 'win32')
196
+ chmodSync(path, 0o600);
197
+ }
198
+ function removeIfOwned(path, owner) {
199
+ try {
200
+ const current = JSON.parse(readFileSync(path, 'utf8'));
201
+ if (current.pid === owner.pid && current.token === owner.token && current.port === owner.port)
202
+ rmSync(path, { force: true });
203
+ }
204
+ catch { }
205
+ }
206
+ function processAlive(pid) {
207
+ if (!Number.isInteger(pid) || pid <= 0)
208
+ return false;
209
+ try {
210
+ process.kill(pid, 0);
211
+ return true;
212
+ }
213
+ catch (error) {
214
+ return error.code === 'EPERM';
215
+ }
216
+ }
217
+ function discoveryAgeMs(path, discovery) {
218
+ const created = Date.parse(discovery.createdAt);
219
+ if (Number.isFinite(created))
220
+ return Math.max(0, Date.now() - created);
221
+ try {
222
+ return Math.max(0, Date.now() - statSync(path).mtimeMs);
223
+ }
224
+ catch {
225
+ return 0;
226
+ }
227
+ }
228
+ function readBody(req) {
229
+ return new Promise((resolve, reject) => {
230
+ let body = '';
231
+ req.on('data', value => {
232
+ body += String(value);
233
+ if (body.length > 1_000_000)
234
+ req.destroy(new Error('Request too large.'));
235
+ });
236
+ req.on('end', () => resolve(body));
237
+ req.on('error', reject);
238
+ });
239
+ }
240
+ function parseError(text) {
241
+ try {
242
+ const parsed = JSON.parse(text);
243
+ return typeof parsed.error === 'string' ? parsed.error : 'RadioCLI control request failed.';
244
+ }
245
+ catch {
246
+ return 'RadioCLI control request failed.';
247
+ }
248
+ }
@@ -14,6 +14,7 @@ import { AlarmPowerGuardStore } from './power-guard-store.js';
14
14
  import { openAlarmControls } from './terminal-launcher.js';
15
15
  import { hasLiveTui } from './tui-presence.js';
16
16
  import { createSystemVolumeController } from './system-volume.js';
17
+ import { connectRadioSession } from '../agent/session.js';
17
18
  export async function runAlarmCommand(args, dependencies = {}) {
18
19
  const store = dependencies.store ?? new JsonLibraryStore();
19
20
  const scheduler = dependencies.scheduler ?? createSchedulerService();
@@ -173,7 +174,9 @@ export async function runAlarmCommand(args, dependencies = {}) {
173
174
  }
174
175
  throw new Error(alarmUsage());
175
176
  }
176
- function createRuntimeDeps(store, scheduler, alarmId) { const providers = new ProviderManager(); const alarm = store.getAlarm(alarmId); const detected = detectPlaybackBackends(); const settings = alarm ? alarmRuntimeSettings(store.snapshot().settings, alarm, detected) : { ...store.snapshot().settings, preferredBackend: 'auto', preferredAirPlayDevice: undefined }; const player = new PlayerController(() => settings); player.refreshDetectedBackends(); const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); return { now: () => new Date(), store, providers, player, scheduler, inhibitor: createPowerInhibitor(), systemVolume: createSystemVolumeController(), acquireLock: acquireOccurrenceLock, createSession: defaultRunnerUtilities.createSession, openControls: () => openAlarmControls({ nodePath: process.execPath, cliPath, hasLiveTui }), wait: defaultRunnerUtilities.wait, subscribeSignals: defaultRunnerUtilities.subscribeSignals, health: scheduler.health }; }
177
+ function createRuntimeDeps(store, scheduler, alarmId) { const providers = new ProviderManager(); const alarm = store.getAlarm(alarmId); const detected = detectPlaybackBackends(); const settings = alarm ? alarmRuntimeSettings(store.snapshot().settings, alarm, detected) : { ...store.snapshot().settings, preferredBackend: 'auto', preferredAirPlayDevice: undefined }; const player = new PlayerController(() => settings); player.refreshDetectedBackends(); const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); return { now: () => new Date(), store, providers, player, scheduler, inhibitor: createPowerInhibitor(), systemVolume: createSystemVolumeController(), preemptInteractivePlayback: async () => { const client = await connectRadioSession(); if (!client)
178
+ return; const result = await client.call({ type: 'alarm-preempt' }); if (!result.ok)
179
+ throw new Error(result.message); }, acquireLock: acquireOccurrenceLock, createSession: defaultRunnerUtilities.createSession, openControls: () => openAlarmControls({ nodePath: process.execPath, cliPath, hasLiveTui }), wait: defaultRunnerUtilities.wait, subscribeSignals: defaultRunnerUtilities.subscribeSignals, health: scheduler.health }; }
177
180
  export function alarmRuntimeSettings(settings, alarm, backends) { const mpvFade = alarm.playback.fadeSeconds > 0 && backends.includes('mpv'); return { ...settings, volume: mpvFade ? 0 : alarm.playback.volume, preferredBackend: mpvFade ? 'mpv' : 'auto', preferredAirPlayDevice: undefined }; }
178
181
  async function testAlarm(alarm, store) {
179
182
  const scheduledAt = new Date(Math.floor(Date.now() / 60_000) * 60_000);
@@ -35,6 +35,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
35
35
  const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
36
36
  const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
37
37
  const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
38
+ let action;
39
+ let keepPlaying = false;
40
+ let resolveAction = () => { };
41
+ const actionPromise = new Promise(resolve => { resolveAction = resolve; });
38
42
  try {
39
43
  if (!alarm || !alarm.enabled)
40
44
  return { message: 'Alarm is missing or disabled.' };
@@ -53,6 +57,22 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
53
57
  outcome = finish('missed', 'Alarm was outside its missed-run grace window.');
54
58
  return { status: outcome.status, message: outcome.message };
55
59
  }
60
+ const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
61
+ const creatingSession = deps.createSession(claimingStatus, {
62
+ onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
63
+ onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
64
+ onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
65
+ onHandoff: () => { if (!listening)
66
+ throw new Error('Alarm playback is still starting.'); preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
67
+ });
68
+ void creatingSession.then(created => { if (signalReceived)
69
+ void created.close().catch(() => undefined); }).catch(() => { });
70
+ const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
71
+ if ('signal' in created) {
72
+ outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
73
+ return { status: 'dismissed', message: outcome.message };
74
+ }
75
+ session = created.value;
56
76
  const claimed = deps.store.getAlarm(alarmId);
57
77
  if (claimed?.enabled && claimed.schedule.type === 'recurring') {
58
78
  if (deps.scheduler.syncClaimed)
@@ -60,16 +80,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
60
80
  else
61
81
  await deps.scheduler.sync(claimed);
62
82
  }
63
- if (deps.systemVolume)
64
- try {
65
- systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
66
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
67
- }
68
- catch (error) {
69
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
70
- }
71
83
  let resolvedStation = alarm.station;
72
84
  let lastError;
85
+ let interactivePreempted = false;
86
+ let outputPrepared = false;
73
87
  const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
74
88
  for (const [candidateIndex, candidate] of candidates.entries()) {
75
89
  try {
@@ -78,6 +92,26 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
78
92
  outcome = finish('dismissed', 'Alarm interrupted before playback started.');
79
93
  return { status: 'dismissed', message: outcome.message };
80
94
  }
95
+ if (!interactivePreempted) {
96
+ interactivePreempted = true;
97
+ try {
98
+ await deps.preemptInteractivePlayback?.();
99
+ }
100
+ catch (error) {
101
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}` });
102
+ }
103
+ }
104
+ if (!outputPrepared) {
105
+ outputPrepared = true;
106
+ if (deps.systemVolume)
107
+ try {
108
+ systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
109
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
110
+ }
111
+ catch (error) {
112
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
113
+ }
114
+ }
81
115
  const playPromise = deps.player.play(candidate, resolved.stream.url);
82
116
  const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
83
117
  if ('signal' in tuned || signalReceived) {
@@ -129,25 +163,8 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
129
163
  deps.store.addRecent(resolvedStation);
130
164
  deps.store.startListeningSession(resolvedStation, startedAt);
131
165
  listening = true;
132
- let action;
133
- let keepPlaying = false;
134
- let resolveAction = () => { };
135
- const actionPromise = new Promise(resolve => { resolveAction = resolve; });
136
166
  const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
137
- const creatingSession = deps.createSession(activeStatus, {
138
- onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); },
139
- onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); },
140
- onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
141
- onHandoff: () => { preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
142
- });
143
- void creatingSession.then(created => { if (signalReceived)
144
- void created.close().catch(() => undefined); }).catch(() => { });
145
- const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
146
- if ('signal' in created) {
147
- outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
148
- return { status: 'dismissed', message: outcome.message };
149
- }
150
- session = created.value;
167
+ session.update(activeStatus);
151
168
  void deps.openControls?.(activeStatus).catch(error => { try {
152
169
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}` });
153
170
  }
package/dist/cli.js CHANGED
@@ -12,12 +12,31 @@ import { resolveCommand } from './player/command.js';
12
12
  import { diagnoseCommand } from './player/command-diagnostics.js';
13
13
  import { airPlaySenderHealth } from './player/airplay-sender-health.js';
14
14
  import { appVersion } from './version.js';
15
- import { checkForUpdate, updateCommandForInstall } from './update-check.js';
15
+ import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
16
16
  import { runAlarmCommand } from './alarms/cli.js';
17
17
  import { runSetup } from './setup.js';
18
+ import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
19
+ import { decodeAgentCommand } from './agent/service.js';
20
+ import { runHeadlessAgentHost } from './agent/headless-host.js';
21
+ import { configureMcpIntegrations } from './agent/mcp-install.js';
22
+ import { defaultAgentControlSettings } from './types.js';
23
+ const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
18
24
  if (isDirectRun(process.argv[1], import.meta.url)) {
19
25
  const args = process.argv.slice(2);
20
- if (args.length > 0) {
26
+ if (args[0] === 'agent-ui') {
27
+ const encoded = args[1];
28
+ if (!encoded)
29
+ throw new Error('Missing RadioCLI agent startup request.');
30
+ const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
31
+ render(_jsx(App, { initialAgentCommand: decodeAgentCommand(encoded) }), {
32
+ exitOnCtrlC: false,
33
+ kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
34
+ });
35
+ }
36
+ else if (args[0] === 'agent-host') {
37
+ await runHeadlessAgentHost();
38
+ }
39
+ else if (args.length > 0) {
21
40
  await runCommand(args).catch(error => {
22
41
  console.error(error instanceof Error ? error.message : String(error));
23
42
  process.exitCode = 1;
@@ -49,6 +68,14 @@ export async function runCommand(args) {
49
68
  await runAlarmCommand(rest);
50
69
  return;
51
70
  }
71
+ if (command === 'mcp') {
72
+ await runMcpCommand(rest, runtime);
73
+ return;
74
+ }
75
+ if (command === 'agent') {
76
+ await runAgentCliCommand(rest, runtime);
77
+ return;
78
+ }
52
79
  if (command === 'setup') {
53
80
  if (rest.includes('--help') || rest.includes('-h')) {
54
81
  printSetupHelp();
@@ -73,6 +100,9 @@ export async function runCommand(args) {
73
100
  return;
74
101
  }
75
102
  if (command === 'update') {
103
+ const unknown = rest.filter(arg => arg !== '--install');
104
+ if (unknown.length > 0)
105
+ throw new Error('Usage: radiocli update [--install]');
76
106
  const updateCheck = await checkForUpdate();
77
107
  const updateCommand = updateCommandForInstall();
78
108
  if (updateCheck.error) {
@@ -84,6 +114,26 @@ export async function runCommand(args) {
84
114
  console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
85
115
  }
86
116
  console.log(`command=${updateCommand.command}`);
117
+ if (rest.includes('--install')) {
118
+ const result = await installUpdate(updateCommand.command);
119
+ if (!result.ok)
120
+ throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
121
+ console.log('updated=yes');
122
+ const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
123
+ if (agentControl.enabled) {
124
+ try {
125
+ const repaired = await configureMcpIntegrations(true, runtime);
126
+ const failed = repaired.filter(item => item.status === 'failed');
127
+ console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
128
+ if (failed.length)
129
+ console.log(`mcp_failures=${failed.map(item => `${item.client}: ${item.detail}`).join('; ')}`);
130
+ }
131
+ catch (error) {
132
+ console.log(`mcp_repaired=failed ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ }
135
+ console.log('restart_required=yes');
136
+ }
87
137
  return;
88
138
  }
89
139
  if (command === 'check') {
@@ -161,7 +211,10 @@ Usage:
161
211
  radiocli check Show provider/backend health
162
212
  radiocli doctor [--json] Show local playback setup guidance
163
213
  radiocli setup Install and verify native playback tools
214
+ radiocli mcp <command> Install, inspect, or run the MCP integration
215
+ radiocli agent <command> Scriptable radio controls for local agents
164
216
  radiocli update Show update availability and install command
217
+ radiocli update --install Install the latest release and repair enabled MCP entries
165
218
  radiocli countries Print top countries
166
219
  radiocli search <query> Search public stations
167
220
  radiocli import <file> Import .m3u, .pls, or .xspf streams
@@ -182,7 +235,7 @@ export function isDirectRun(entryPath, moduleUrl) {
182
235
  }
183
236
  }
184
237
  function isKnownCommand(command) {
185
- return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm'].includes(command);
238
+ return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm', 'mcp', 'agent'].includes(command);
186
239
  }
187
240
  function printSetupHelp() {
188
241
  console.log(`RadioCLI Setup
@@ -193,6 +246,10 @@ Usage:
193
246
  radiocli setup --all --yes Install mpv, FFmpeg, and VLC
194
247
  radiocli setup --only mpv,ffmpeg Select specific components
195
248
  radiocli setup --dry-run Show commands without installing
249
+ radiocli setup --mcp Enable and configure agent MCP clients
250
+ radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
251
+ radiocli setup --mcp --headless-agent Opt out of external terminal windows
252
+ radiocli setup --no-mcp Disable and remove agent MCP entries
196
253
  radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
197
254
  apt, dnf, pacman, apk, or zypper
198
255
  `);
@@ -230,6 +230,20 @@ export class PlayerController {
230
230
  });
231
231
  return { ok: true };
232
232
  }
233
+ async pause() {
234
+ if (this.state.state === 'paused')
235
+ return { ok: true };
236
+ if (this.state.state !== 'playing')
237
+ return { ok: false, message: 'RadioCLI is not currently playing.' };
238
+ return this.togglePause();
239
+ }
240
+ async resume() {
241
+ if (this.state.state === 'playing')
242
+ return { ok: true };
243
+ if (this.state.state !== 'paused')
244
+ return { ok: false, message: 'RadioCLI has no paused station to resume.' };
245
+ return this.togglePause();
246
+ }
233
247
  setVolume(volume) {
234
248
  const clamped = clampVolume(volume);
235
249
  const unsupported = this.unsupportedFfplayControl();
@@ -272,6 +286,11 @@ export class PlayerController {
272
286
  this.setState({ ...this.state, muted });
273
287
  return { ok: true };
274
288
  }
289
+ async setMuted(muted) {
290
+ if (this.state.muted === muted)
291
+ return { ok: true };
292
+ return this.toggleMute();
293
+ }
275
294
  stop() {
276
295
  if (this.stopPromise)
277
296
  return this.stopPromise;
@@ -16,6 +16,11 @@ export class ProviderManager {
16
16
  byCountry(countryCode, limit, offset) {
17
17
  return this.radioBrowser.byCountry(countryCode, limit, offset);
18
18
  }
19
+ byId(provider, id) {
20
+ if (provider !== 'radio-browser')
21
+ return Promise.resolve(null);
22
+ return this.radioBrowser.byId(id);
23
+ }
19
24
  nearby(location, limit) {
20
25
  return this.radioBrowser.nearby(location, limit);
21
26
  }
@@ -115,6 +115,10 @@ export class RadioBrowserProvider {
115
115
  }, { maxAgeMs: 30 * 60 * 1000 });
116
116
  return this.normalizeStations(rows);
117
117
  }
118
+ async byId(id) {
119
+ const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
120
+ return this.normalizeStations(rows)[0] ?? null;
121
+ }
118
122
  async search(query, options = {}) {
119
123
  const trimmed = query.trim();
120
124
  if (!trimmed) {