| 1 | import { execFileSync, spawn, spawnSync } from 'node:child_process'; |
| 2 | import { copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; |
| 3 | import { homedir, hostname } from 'node:os'; |
| 4 | import { delimiter, dirname, join, resolve } from 'node:path'; |
| 5 | import { createInterface } from 'node:readline/promises'; |
| 6 | import { setTimeout as sleep } from 'node:timers/promises'; |
| 7 | |
| 8 | const [action = 'status', server, installDir] = process.argv.slice(2); |
| 9 | const base = installDir ?? dirname(process.argv[1]); |
| 10 | const windows = process.platform === 'win32'; |
| 11 | const mac = process.platform === 'darwin'; |
| 12 | const data = windows ? join(base, 'config') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'agent-relay'); |
| 13 | const 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'); |
| 15 | const task = windows ? 'AgentRelay-' + execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' }).match(/S-1-5-[\d-]+/)[0] : ''; |
| 16 | const domain = mac ? `gui/${process.getuid()}` : ''; |
| 17 | const ps = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' }); |
| 18 | const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'"; |
| 19 | const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'"; |
| 20 | |
| 21 | async function stop() { |
| 22 | if (windows) ps(`$ErrorActionPreference = 'Stop' |
| 23 | if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} } |
| 24 | Get-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 | |
| 45 | async 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 |
| 158 | Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null |
| 159 | Start-ScheduledTask -TaskName ${psQuote(task)} |
| 160 | if ((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 | |
| 192 | main().catch((error) => { console.error(error.message); process.exitCode = 1; }); |