1import { execFileSync, spawn, spawnSync } from 'node:child_process';
2import { copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3import { homedir, hostname } from 'node:os';
4import { delimiter, dirname, join, resolve } from 'node:path';
5import { createInterface } from 'node:readline/promises';
6import { setTimeout as sleep } from 'node:timers/promises';
7
8const [action = 'status', server, installDir] = process.argv.slice(2);
9const base = installDir ?? dirname(process.argv[1]);
10const windows = process.platform === 'win32';
11const mac = process.platform === 'darwin';
12const data = windows ? join(base, 'config') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'agent-relay');
13const unit = mac ? join(homedir(), 'Library/LaunchAgents/net.paperclover.agent-relay.plist') :
14 join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'systemd/user/agent-relay.service');
15const task = windows ? 'AgentRelay-' + execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' }).match(/S-1-5-[\d-]+/)[0] : '';
16const domain = mac ? `gui/${process.getuid()}` : '';
17const ps = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' });
18const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'";
19const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'";
20
21async function stop() {
22 if (windows) ps(`$ErrorActionPreference = 'Stop'
23if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} }
24Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object {
25 $_.ExecutablePath -eq ${psQuote(join(base, 'node.exe'))} -and $_.CommandLine.Contains(${psQuote(join(base, 'relay.mjs'))})
26} | ForEach-Object {
27 & taskkill.exe /PID $_.ProcessId /T /F | Out-Null
28 if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue)) { throw 'Unable to stop the previous agent. Close it and run the installer again.' }
29}`);
30 else if (mac) {
31 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status !== 0) return;
32 execFileSync('launchctl', ['bootout', `${domain}/net.paperclover.agent-relay`]);
33 for (let attempt = 0; attempt < 40; attempt++) {
34 try { execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); }
35 catch { return; }
36 await sleep(250);
37 }
38 throw new Error('The previous agent is still stopping. Wait and run the installer again.');
39 }
40 else if (spawnSync('systemctl', ['--user', 'show', '-P', 'LoadState', 'agent-relay.service'], { encoding: 'utf8' }).stdout.trim() === 'loaded') {
41 execFileSync('systemctl', ['--user', 'stop', 'agent-relay.service']);
42 }
43}
44
45async function main() {
46 if (action === 'stop') { await stop(); return; }
47 if (action === 'start') {
48 if (windows) ps(`$ErrorActionPreference = 'Stop'; Start-ScheduledTask -TaskName ${psQuote(task)}`);
49 else if (mac) {
50 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status === 0) {
51 execFileSync('launchctl', ['kickstart', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
52 } else execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
53 }
54 else execFileSync('systemctl', ['--user', 'start', 'agent-relay.service'], { stdio: 'inherit' });
55 return;
56 }
57 if (action === 'status') {
58 if (windows) ps(`$ErrorActionPreference = 'Stop'; Get-ScheduledTask -TaskName ${psQuote(task)} | Select-Object TaskName,State`);
59 else if (mac) execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
60 else execFileSync('systemctl', ['--user', 'status', '--no-pager', 'agent-relay.service'], { stdio: 'inherit' });
61 return;
62 }
63 if (action === 'uninstall') {
64 await stop();
65 if (windows) ps(`$ErrorActionPreference = 'Stop'; Unregister-ScheduledTask -TaskName ${psQuote(task)} -Confirm:$false`);
66 else {
67 if (!mac) execFileSync('systemctl', ['--user', 'disable', 'agent-relay.service'], { stdio: 'inherit' });
68 await rm(unit, { force: true });
69 if (!mac) execFileSync('systemctl', ['--user', 'daemon-reload']);
70 }
71 console.log(`Startup removed. Pairing and files remain in ${base} and ${data}.`);
72 return;
73 }
74 if (action !== 'install' || !server || !installDir) throw new Error('Use install, status, start, stop, or uninstall.');
75 const origin = new URL(server);
76 if (origin.origin !== server || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) {
77 throw new Error('Use an HTTPS dashboard origin, or localhost for a preview.');
78 }
79 const staged = dirname(process.argv[1]);
80 let previous;
81 try { previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); }
82 catch (error) { if (error.code !== 'ENOENT') throw error; }
83 if (previous && previous.server !== server) throw new Error(`This machine is paired to ${previous.server}. Unlink it there before changing dashboards.`);
84 const input = createInterface({ input: process.stdin, output: process.stdout });
85 const closed = new AbortController();
86 input.once('close', () => closed.abort());
87 const ask = (text) => input.question(text, { signal: closed.signal });
88 let name, desktopWrite;
89 const roots = [];
90 try {
91 console.log(`Agent Relay · ${server}\nCodex and Claude Code must already be installed and signed in.`);
92 console.log('Linked clients can read saved Codex and Claude Code chats on this machine.');
93 name = previous ? 'this machine' : (await ask(`Machine name [${hostname()}]: `)).trim() || hostname();
94 if (previous) console.log('The existing machine pairing will be kept.');
95 if (name.length > 100) throw new Error('Enter a machine name up to 100 characters.');
96 console.log('Allowed folders apply to new chats. Existing chats keep their own permissions.');
97 if (previous?.roots?.length) console.log(`Current folders: ${previous.roots.join(', ')}`);
98 console.log('Enter one project folder at a time. Leave blank to finish.');
99 if (previous) console.log('Leave the first answer blank to keep the current folders. Enter - to clear them.');
100 while (true) {
101 const answer = (await ask('Project folder: ')).trim();
102 if (!answer) { if (!roots.length && previous) roots.push(...previous.roots); break; }
103 if (answer === '-' && !roots.length) break;
104 const path = await realpath(resolve(answer === '~' ? homedir() : answer.startsWith('~/') ? join(homedir(), answer.slice(2)) : answer));
105 if (!(await stat(path)).isDirectory()) throw new Error('Choose an existing project folder.');
106 if (!roots.includes(path)) roots.push(path);
107 }
108 if (!windows) {
109 console.log('Experimental Codex desktop control lets linked clients send messages and interrupt chats. App updates may break it.');
110 const answer = (await ask(`Enable desktop control? [${previous?.desktopWrite ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase();
111 if (answer && !['y', 'yes', 'n', 'no'].includes(answer)) throw new Error('Answer yes or no.');
112 desktopWrite = answer ? ['y', 'yes'].includes(answer) : previous?.desktopWrite ?? false;
113 } else desktopWrite = false;
114 } catch (error) {
115 if (closed.signal.aborted) throw new Error('Keep the terminal open to answer the install prompts, then run the installer again.');
116 throw error;
117 } finally { input.close(); }
118 await mkdir(data, { recursive: true, mode: 0o700 });
119 if (previous) {
120 const response = await fetch(`${server}/pairing`, { headers: { Authorization: `Bearer ${previous.token}` }, signal: AbortSignal.timeout(10_000) });
121 if (!response.ok) throw new Error(`The saved pairing is unavailable (${response.status}). Check the dashboard before reinstalling.`);
122 } else {
123 await new Promise((accept, reject) => {
124 const child = spawn(process.execPath, [join(staged, 'relay.mjs'), 'pair', '--server', server, '--name', name, '--data-dir', data,
125 ...roots.flatMap((root) => ['--allow-root', root]), ...(desktopWrite ? ['--codex-desktop-write'] : [])], { stdio: 'inherit' });
126 child.on('error', reject);
127 child.on('exit', (code) => code === 0 ? accept() : reject(new Error('Pairing stopped. Run the installer again for a new code.')));
128 });
129 previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8'));
130 }
131 const config = { ...previous, roots, desktopWrite };
132 const binaries = {};
133 for (const cli of ['codex', 'claude']) {
134 try {
135 const found = windows ? execFileSync('where.exe', [cli], { encoding: 'utf8' }).trim().split(/\r?\n/)[0] :
136 execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', cli], { encoding: 'utf8' }).trim();
137 if (found) binaries[cli] = found;
138 } catch { console.log(`${cli} was not found. Install it and rerun this installer to enable its chats.`); }
139 }
140 await stop();
141 await writeFile(join(data, 'agent.json.pending'), JSON.stringify(config) + '\n', { mode: 0o600 });
142 await rename(join(data, 'agent.json.pending'), join(data, 'agent.json'));
143 for (const file of ['relay.mjs', 'setup.mjs']) await copyFile(join(staged, file), join(base, file));
144 const args = [join(base, 'relay.mjs'), 'run', '--data-dir', data,
145 ...Object.entries(binaries).flatMap(([cli, path]) => [`--${cli}-bin`, path])];
146 const environment = Object.fromEntries(['PATH', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
147 environment.PATH = [base, environment.PATH].filter(Boolean).join(delimiter);
148 if (windows) {
149 const runner = join(base, 'run.ps1');
150 await writeFile(runner, "$ErrorActionPreference = 'Stop'\n" + Object.entries(environment).map(([key, value]) => `$env:${key} = ${psQuote(value)}`).join('\n') +
151 `\n& ${psQuote(process.execPath)} ${args.map(psQuote).join(' ')} *>> ${psQuote(join(base, 'agent.log'))}\nexit $LASTEXITCODE\n`);
152 ps(`$ErrorActionPreference = 'Stop'
153$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
154$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runner}"`)}
155$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
156$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
157$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
158Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
159Start-ScheduledTask -TaskName ${psQuote(task)}
160if ((Get-ScheduledTask -TaskName ${psQuote(task)}).State -eq 'Disabled') { throw 'Enable the Agent Relay task and run the installer again.' }`);
161 await writeFile(join(base, 'agent-relay.cmd'), `@echo off\r\n"${process.execPath}" "${join(base, 'setup.mjs')}" %*\r\n`);
162 } else {
163 await mkdir(dirname(unit), { recursive: true });
164 if (mac) {
165 const xml = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
166 await writeFile(unit, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
167<key>Label</key><string>net.paperclover.agent-relay</string>
168<key>ProgramArguments</key><array>${[process.execPath, ...args].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array>
169<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([key, value]) => `<key>${key}</key><string>${xml(value)}</string>`).join('')}</dict>
170<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer>
171<key>StandardOutPath</key><string>${xml(join(base, 'agent.log'))}</string>
172<key>StandardErrorPath</key><string>${xml(join(base, 'agent.log'))}</string>
173</dict></plist>\n`);
174 execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
175 execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' });
176 } else {
177 const quote = (text) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('%', '%%') + '"';
178 await writeFile(unit, `[Unit]\nDescription=Agent Relay\n\n[Service]\nExecStart=${[process.execPath, ...args].map((arg) => quote(arg).replaceAll('$', '$$')).join(' ')}\n` +
179 Object.entries(environment).map(([key, value]) => `Environment=${quote(`${key}=${value}`)}`).join('\n') +
180 '\nRestart=on-failure\nRestartSec=30\nUMask=0077\n\n[Install]\nWantedBy=default.target\n');
181 execFileSync('systemctl', ['--user', 'daemon-reload']);
182 execFileSync('systemctl', ['--user', 'enable', '--now', 'agent-relay.service'], { stdio: 'inherit' });
183 execFileSync('systemctl', ['--user', 'is-active', '--quiet', 'agent-relay.service']);
184 }
185 await writeFile(join(base, 'agent-relay'), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(base, 'setup.mjs'))} "$@"\n`, { mode: 0o700 });
186 }
187 console.log(`Installed. Agent Relay starts at login.\nOpen ${server}/mcp/settings/agents and check that ${name} is online.`);
188 const manage = join(base, windows ? 'agent-relay.cmd' : 'agent-relay');
189 console.log(`Check startup: ${windows ? '& ' + psQuote(manage) : shellQuote(manage)} status\nUse start, stop, or uninstall in place of status.`);
190}
191
192main().catch((error) => { console.error(error.message); process.exitCode = 1; });