@ciphore/radiocli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { AgentRadioService, stationForAgent } from './service.js';
|
|
2
|
+
import { configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig } from './mcp-install.js';
|
|
3
|
+
import { runMcpServer } from './mcp-server.js';
|
|
4
|
+
export async function runMcpCommand(args, runtime) {
|
|
5
|
+
const command = args[0] ?? 'status';
|
|
6
|
+
if (command === 'serve')
|
|
7
|
+
return runMcpServer(runtime);
|
|
8
|
+
if (command === 'enable' || command === 'install' || command === 'repair') {
|
|
9
|
+
assertMcpInstallSucceeded(await configureMcpIntegrations(true, runtime), 'configured');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (command === 'disable' || command === 'remove') {
|
|
13
|
+
assertMcpInstallSucceeded(await configureMcpIntegrations(false, runtime), 'removed');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (command === 'config') {
|
|
17
|
+
console.log(JSON.stringify(portableMcpConfig(runtime), null, 2));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (command === 'status' || command === 'doctor') {
|
|
21
|
+
console.log(JSON.stringify(await mcpIntegrationReport(runtime), null, 2));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>');
|
|
25
|
+
}
|
|
26
|
+
export async function runAgentCliCommand(args, runtime) {
|
|
27
|
+
const service = new AgentRadioService(runtime);
|
|
28
|
+
const [command = 'status', ...rest] = args;
|
|
29
|
+
let value;
|
|
30
|
+
if (command === 'status')
|
|
31
|
+
value = await service.status();
|
|
32
|
+
else if (command === 'search')
|
|
33
|
+
value = (await service.search(requiredText(rest, 'Usage: radiocli agent search <query>'))).map(stationForAgent);
|
|
34
|
+
else if (command === 'browse') {
|
|
35
|
+
const kind = parseBrowseKind(rest[0]);
|
|
36
|
+
if (rest.length > 2)
|
|
37
|
+
throw new Error('Usage: radiocli agent browse [favorites|recent|popular|nearby|countries|track-history] [1-50]');
|
|
38
|
+
const items = await service.browse(kind, rest[1] === undefined ? 20 : integerInRange(rest[1], 1, 50, 'Browse limit'));
|
|
39
|
+
value = ['favorites', 'recent', 'popular', 'nearby'].includes(kind)
|
|
40
|
+
? items.map(stationForAgent)
|
|
41
|
+
: items;
|
|
42
|
+
}
|
|
43
|
+
else if (command === 'play')
|
|
44
|
+
value = await service.play(parsePlayArgs(rest));
|
|
45
|
+
else if (command === 'pause' || command === 'resume' || command === 'stop' || command === 'next' || command === 'previous')
|
|
46
|
+
value = await service.control({ type: command });
|
|
47
|
+
else if (command === 'volume') {
|
|
48
|
+
if (rest.length !== 1)
|
|
49
|
+
throw new Error('Usage: radiocli agent volume <0-100>');
|
|
50
|
+
value = await service.control({ type: 'set-volume', volume: numberInRange(rest[0], 0, 100, 'Volume') });
|
|
51
|
+
}
|
|
52
|
+
else if (command === 'mute' || command === 'unmute')
|
|
53
|
+
value = await service.control({ type: 'set-muted', muted: command === 'mute' });
|
|
54
|
+
else if (command === 'favorite' || command === 'unfavorite')
|
|
55
|
+
value = await service.setFavorite(command === 'favorite', rest[0]);
|
|
56
|
+
else if (command === 'stats')
|
|
57
|
+
value = service.stats();
|
|
58
|
+
else if (command === 'appearance')
|
|
59
|
+
value = await parseAppearance(rest, service);
|
|
60
|
+
else if (command === 'airplay')
|
|
61
|
+
value = await runAirPlayCommand(rest, service);
|
|
62
|
+
else if (command === 'preset')
|
|
63
|
+
value = service.configureCompletionPreset(parsePreset(rest));
|
|
64
|
+
else if (command === 'done')
|
|
65
|
+
value = await service.runCompletionPreset();
|
|
66
|
+
else
|
|
67
|
+
throw new Error('Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance|airplay|preset|done>');
|
|
68
|
+
console.log(JSON.stringify(value, null, 2));
|
|
69
|
+
}
|
|
70
|
+
function parseBrowseKind(value) {
|
|
71
|
+
const kind = value ?? 'recent';
|
|
72
|
+
if (!['favorites', 'recent', 'popular', 'nearby', 'countries', 'track-history'].includes(kind))
|
|
73
|
+
throw new Error('Browse kind must be favorites, recent, popular, nearby, countries, or track-history.');
|
|
74
|
+
return kind;
|
|
75
|
+
}
|
|
76
|
+
function parsePlayArgs(args) {
|
|
77
|
+
const result = {};
|
|
78
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
79
|
+
const arg = args[index];
|
|
80
|
+
if (arg === '--source')
|
|
81
|
+
result.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
|
|
82
|
+
else if (arg === '--country') {
|
|
83
|
+
result.source = 'country';
|
|
84
|
+
result.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
|
|
85
|
+
}
|
|
86
|
+
else if (arg === '--random')
|
|
87
|
+
result.random = true;
|
|
88
|
+
else if (arg === '--no-ui')
|
|
89
|
+
result.openUi = false;
|
|
90
|
+
else if (arg === '--keep-playing')
|
|
91
|
+
result.ifPlaying = 'keep';
|
|
92
|
+
else if (!result.stationId)
|
|
93
|
+
result.stationId = arg;
|
|
94
|
+
else
|
|
95
|
+
throw new Error(`Unknown play option: ${arg}`);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function parseAppearance(args, service) {
|
|
100
|
+
if (args.length === 0)
|
|
101
|
+
return service.appearance();
|
|
102
|
+
const input = {};
|
|
103
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
104
|
+
const arg = args[index];
|
|
105
|
+
if (arg === '--theme')
|
|
106
|
+
input.theme = required(args[++index], '--theme requires a theme name.');
|
|
107
|
+
else if (arg === '--receiver-style')
|
|
108
|
+
input.receiverStyle = required(args[++index], '--receiver-style requires a style name.');
|
|
109
|
+
else
|
|
110
|
+
throw new Error(`Unknown appearance option: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
return service.updateAppearance(input);
|
|
113
|
+
}
|
|
114
|
+
function parsePreset(args) {
|
|
115
|
+
const input = {};
|
|
116
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
117
|
+
const arg = args[index];
|
|
118
|
+
if (arg === '--action')
|
|
119
|
+
input.action = presetAction(required(args[++index], '--action requires play, pause, resume, or stop.'));
|
|
120
|
+
else if (arg === '--source')
|
|
121
|
+
input.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
|
|
122
|
+
else if (arg === '--country') {
|
|
123
|
+
input.source = 'country';
|
|
124
|
+
input.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
|
|
125
|
+
}
|
|
126
|
+
else if (arg === '--keep-playing')
|
|
127
|
+
input.ifPlaying = 'keep';
|
|
128
|
+
else if (arg === '--replace')
|
|
129
|
+
input.ifPlaying = 'replace';
|
|
130
|
+
else if (arg === '--open-ui')
|
|
131
|
+
input.openUi = true;
|
|
132
|
+
else if (arg === '--no-ui')
|
|
133
|
+
input.openUi = false;
|
|
134
|
+
else
|
|
135
|
+
throw new Error(`Unknown preset option: ${arg}`);
|
|
136
|
+
}
|
|
137
|
+
return input;
|
|
138
|
+
}
|
|
139
|
+
async function runAirPlayCommand(args, service) {
|
|
140
|
+
const [action = 'list', ...rest] = args;
|
|
141
|
+
if (action === 'list')
|
|
142
|
+
return service.listAirPlayDevices();
|
|
143
|
+
if (action === 'select')
|
|
144
|
+
return service.selectAirPlayDevice(required(rest[0], 'Usage: radiocli agent airplay select <receiver-id>'), !rest.includes('--no-ui'));
|
|
145
|
+
if (action === 'local')
|
|
146
|
+
return service.useLocalOutput();
|
|
147
|
+
if (action === 'code')
|
|
148
|
+
return service.submitAirPlayPasscode(required(rest[0], 'Usage: radiocli agent airplay code <receiver-code>'));
|
|
149
|
+
throw new Error('Usage: radiocli agent airplay <list|select|local|code>');
|
|
150
|
+
}
|
|
151
|
+
function required(value, error) { if (!value)
|
|
152
|
+
throw new Error(error); return value; }
|
|
153
|
+
function requiredText(values, error) {
|
|
154
|
+
const value = values.join(' ').trim();
|
|
155
|
+
if (!value)
|
|
156
|
+
throw new Error(error);
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
function numberInRange(value, minimum, maximum, label) {
|
|
160
|
+
const parsed = Number(value);
|
|
161
|
+
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
|
|
162
|
+
throw new Error(`${label} must be a number from ${minimum} through ${maximum}.`);
|
|
163
|
+
}
|
|
164
|
+
return parsed;
|
|
165
|
+
}
|
|
166
|
+
function integerInRange(value, minimum, maximum, label) {
|
|
167
|
+
const parsed = numberInRange(value, minimum, maximum, label);
|
|
168
|
+
if (!Number.isInteger(parsed))
|
|
169
|
+
throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
|
|
170
|
+
return parsed;
|
|
171
|
+
}
|
|
172
|
+
function stationSource(value) {
|
|
173
|
+
if (!['recent', 'favorite', 'popular', 'country'].includes(value)) {
|
|
174
|
+
throw new Error('Source must be recent, favorite, popular, or country.');
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function presetAction(value) {
|
|
179
|
+
if (!['play', 'pause', 'resume', 'stop'].includes(value))
|
|
180
|
+
throw new Error('Action must be play, pause, resume, or stop.');
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
function countryCode(value) {
|
|
184
|
+
if (!/^[a-z]{2}$/i.test(value))
|
|
185
|
+
throw new Error('Country must be a two-letter ISO country code.');
|
|
186
|
+
return value.toUpperCase();
|
|
187
|
+
}
|
|
188
|
+
function assertMcpInstallSucceeded(results, action) {
|
|
189
|
+
const failed = results.filter(result => result.status === 'failed');
|
|
190
|
+
if (failed.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
throw new Error(`RadioCLI was ${action} where possible, but ${failed.length} integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => `${result.client} (${result.detail})`).join('; ')}`);
|
|
193
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { PlayerController } from '../player/player-controller.js';
|
|
2
|
+
import { ProviderManager } from '../providers/provider-manager.js';
|
|
3
|
+
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
4
|
+
import { startRadioSession } from './session.js';
|
|
5
|
+
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
6
|
+
export async function runHeadlessAgentHost() {
|
|
7
|
+
const store = new JsonLibraryStore();
|
|
8
|
+
const providers = new ProviderManager();
|
|
9
|
+
const player = new PlayerController(() => store.snapshot().settings);
|
|
10
|
+
player.refreshDetectedBackends();
|
|
11
|
+
let station = null;
|
|
12
|
+
let queue = [];
|
|
13
|
+
const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, output: {
|
|
14
|
+
preferredBackend: store.snapshot().settings.preferredBackend,
|
|
15
|
+
preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
|
|
16
|
+
} });
|
|
17
|
+
const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
|
|
18
|
+
const play = async (next, nextQueue = [next]) => {
|
|
19
|
+
store.finishActiveListeningSession();
|
|
20
|
+
await player.stop();
|
|
21
|
+
const resolved = await providers.resolve(next);
|
|
22
|
+
await player.play(next, resolved.url);
|
|
23
|
+
station = next;
|
|
24
|
+
queue = nextQueue.length ? nextQueue : [next];
|
|
25
|
+
store.addRecent(next);
|
|
26
|
+
store.startListeningSession(next);
|
|
27
|
+
return result(`Playing ${next.name}.`);
|
|
28
|
+
};
|
|
29
|
+
const handle = async (command) => {
|
|
30
|
+
if (command.type === 'status')
|
|
31
|
+
return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
|
|
32
|
+
if (command.type === 'play') {
|
|
33
|
+
if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
34
|
+
return result(`Kept current station ${station?.name ?? ''}.`);
|
|
35
|
+
return play(command.station, command.queue);
|
|
36
|
+
}
|
|
37
|
+
if (command.type === 'pause') {
|
|
38
|
+
const control = await player.pause();
|
|
39
|
+
if (control.ok)
|
|
40
|
+
store.finishActiveListeningSession();
|
|
41
|
+
return result(control.message ?? 'Paused.', control.ok);
|
|
42
|
+
}
|
|
43
|
+
if (command.type === 'resume') {
|
|
44
|
+
const control = await player.resume();
|
|
45
|
+
if (control.ok && station)
|
|
46
|
+
store.startListeningSession(station);
|
|
47
|
+
return result(control.message ?? 'Resumed.', control.ok);
|
|
48
|
+
}
|
|
49
|
+
if (command.type === 'stop') {
|
|
50
|
+
store.finishActiveListeningSession();
|
|
51
|
+
await player.stop();
|
|
52
|
+
station = null;
|
|
53
|
+
queue = [];
|
|
54
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50).unref();
|
|
55
|
+
return result('Stopped RadioCLI.');
|
|
56
|
+
}
|
|
57
|
+
if (command.type === 'alarm-preempt') {
|
|
58
|
+
store.finishActiveListeningSession();
|
|
59
|
+
await player.stop();
|
|
60
|
+
station = null;
|
|
61
|
+
queue = [];
|
|
62
|
+
return result('Interactive playback yielded to the alarm.');
|
|
63
|
+
}
|
|
64
|
+
if (command.type === 'set-volume') {
|
|
65
|
+
const control = await player.setVolume(command.volume);
|
|
66
|
+
if (control.ok)
|
|
67
|
+
store.updateSettings({ volume: player.getState().volume });
|
|
68
|
+
return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
69
|
+
}
|
|
70
|
+
if (command.type === 'set-muted') {
|
|
71
|
+
const control = await player.setMuted(command.muted);
|
|
72
|
+
return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
|
|
73
|
+
}
|
|
74
|
+
if (command.type === 'set-favorite') {
|
|
75
|
+
const target = command.station ?? station;
|
|
76
|
+
if (!target)
|
|
77
|
+
return result('No active station to favorite.', false);
|
|
78
|
+
const current = store.isFavorite(target);
|
|
79
|
+
if (current !== command.favorite)
|
|
80
|
+
store.toggleFavorite(target);
|
|
81
|
+
if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
|
|
82
|
+
void providers.vote(target);
|
|
83
|
+
return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
|
|
84
|
+
}
|
|
85
|
+
if (command.type === 'airplay-list') {
|
|
86
|
+
const devices = await player.refreshAirPlayDevices();
|
|
87
|
+
return result(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
|
|
88
|
+
}
|
|
89
|
+
if (command.type === 'airplay-select') {
|
|
90
|
+
const devices = await player.refreshAirPlayDevices();
|
|
91
|
+
const device = devices.find(item => item.id === command.deviceId);
|
|
92
|
+
if (!device)
|
|
93
|
+
return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
94
|
+
if (device.local)
|
|
95
|
+
return switchToLocal();
|
|
96
|
+
if (!detectPlaybackBackends().includes('airplay'))
|
|
97
|
+
return result('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
98
|
+
store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
99
|
+
store.updateSettings({ preferredBackend: 'airplay' });
|
|
100
|
+
if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
101
|
+
return play(station, queue);
|
|
102
|
+
return result(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
103
|
+
}
|
|
104
|
+
if (command.type === 'airplay-local')
|
|
105
|
+
return switchToLocal();
|
|
106
|
+
if (command.type === 'airplay-passcode') {
|
|
107
|
+
const control = player.submitAirPlayPasscode(command.code);
|
|
108
|
+
return result(control.message ?? 'AirPlay code sent.', control.ok);
|
|
109
|
+
}
|
|
110
|
+
if (command.type === 'update-settings') {
|
|
111
|
+
store.updateSettings(command.settings);
|
|
112
|
+
return result('RadioCLI settings updated.');
|
|
113
|
+
}
|
|
114
|
+
const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
|
|
115
|
+
const delta = command.type === 'next' ? 1 : -1;
|
|
116
|
+
const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
|
|
117
|
+
return next ? play(next, queue) : result('No playback queue is available.', false);
|
|
118
|
+
};
|
|
119
|
+
const switchToLocal = async () => {
|
|
120
|
+
const backends = detectPlaybackBackends();
|
|
121
|
+
const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
|
|
122
|
+
if (!backend)
|
|
123
|
+
return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
124
|
+
store.updateSettings({ preferredBackend: backend });
|
|
125
|
+
if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
126
|
+
return play(station, queue);
|
|
127
|
+
return result(`Audio output set to this device (${backend}).`);
|
|
128
|
+
};
|
|
129
|
+
const session = await startRadioSession(handle);
|
|
130
|
+
const checkpoint = setInterval(() => {
|
|
131
|
+
if (player.getState().state === 'playing')
|
|
132
|
+
store.checkpointActiveListeningSession();
|
|
133
|
+
}, 30_000);
|
|
134
|
+
const shutdown = async () => {
|
|
135
|
+
clearInterval(checkpoint);
|
|
136
|
+
store.finishActiveListeningSession();
|
|
137
|
+
await player.stop();
|
|
138
|
+
await session.close();
|
|
139
|
+
};
|
|
140
|
+
process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); });
|
|
141
|
+
process.once('SIGINT', () => { void shutdown().finally(() => process.exit(0)); });
|
|
142
|
+
await new Promise(() => undefined);
|
|
143
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
}
|
|
37
|
+
}
|
|
38
|
+
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;
|
|
71
|
+
}
|