feat: add /remote-control command for host-level Claude Code access

Users can send /remote-control from the main group in any channel to
spawn a detached `claude remote-control` process on the host. The
session URL is sent back through the channel. /remote-control-end
kills the session.

Key design decisions:
- One global session at a time, restricted to main group only
- Process is fully detached (stdout/stderr to files, not pipes) so it
  survives NanoClaw restarts
- PID + URL persisted to data/remote-control.json; restored on startup
- Commands intercepted in onMessage before DB storage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-03-14 16:59:52 +02:00
parent 2640973b41
commit e2b0d2d0aa
3 changed files with 650 additions and 0 deletions

216
src/remote-control.ts Normal file
View File

@@ -0,0 +1,216 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
interface RemoteControlSession {
pid: number;
url: string;
startedBy: string;
startedInChat: string;
startedAt: string;
}
let activeSession: RemoteControlSession | null = null;
const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
const URL_TIMEOUT_MS = 30_000;
const URL_POLL_MS = 200;
const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
function saveState(session: RemoteControlSession): void {
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(session));
}
function clearState(): void {
try {
fs.unlinkSync(STATE_FILE);
} catch {
// ignore
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Restore session from disk on startup.
* If the process is still alive, adopt it. Otherwise, clean up.
*/
export function restoreRemoteControl(): void {
let data: string;
try {
data = fs.readFileSync(STATE_FILE, 'utf-8');
} catch {
return;
}
try {
const session: RemoteControlSession = JSON.parse(data);
if (session.pid && isProcessAlive(session.pid)) {
activeSession = session;
logger.info(
{ pid: session.pid, url: session.url },
'Restored Remote Control session from previous run',
);
} else {
clearState();
}
} catch {
clearState();
}
}
export function getActiveSession(): RemoteControlSession | null {
return activeSession;
}
/** @internal — exported for testing only */
export function _resetForTesting(): void {
activeSession = null;
}
/** @internal — exported for testing only */
export function _getStateFilePath(): string {
return STATE_FILE;
}
export async function startRemoteControl(
sender: string,
chatJid: string,
cwd: string,
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
if (activeSession) {
// Verify the process is still alive
if (isProcessAlive(activeSession.pid)) {
return { ok: true, url: activeSession.url };
}
// Process died — clean up and start a new one
activeSession = null;
clearState();
}
// Redirect stdout/stderr to files so the process has no pipes to the parent.
// This prevents SIGPIPE when NanoClaw restarts.
fs.mkdirSync(DATA_DIR, { recursive: true });
const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
const stderrFd = fs.openSync(STDERR_FILE, 'w');
let proc;
try {
proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
cwd,
stdio: ['ignore', stdoutFd, stderrFd],
detached: true,
});
} catch (err: any) {
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
return { ok: false, error: `Failed to start: ${err.message}` };
}
// Close FDs in the parent — the child inherited copies
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
// Fully detach from parent
proc.unref();
const pid = proc.pid;
if (!pid) {
return { ok: false, error: 'Failed to get process PID' };
}
// Poll the stdout file for the URL
return new Promise((resolve) => {
const startTime = Date.now();
const poll = () => {
// Check if process died
if (!isProcessAlive(pid)) {
resolve({ ok: false, error: 'Process exited before producing URL' });
return;
}
// Check for URL in stdout file
let content = '';
try {
content = fs.readFileSync(STDOUT_FILE, 'utf-8');
} catch {
// File might not have content yet
}
const match = content.match(URL_REGEX);
if (match) {
const session: RemoteControlSession = {
pid,
url: match[0],
startedBy: sender,
startedInChat: chatJid,
startedAt: new Date().toISOString(),
};
activeSession = session;
saveState(session);
logger.info(
{ url: match[0], pid, sender, chatJid },
'Remote Control session started',
);
resolve({ ok: true, url: match[0] });
return;
}
// Timeout check
if (Date.now() - startTime >= URL_TIMEOUT_MS) {
try {
process.kill(-pid, 'SIGTERM');
} catch {
try {
process.kill(pid, 'SIGTERM');
} catch {
// already dead
}
}
resolve({
ok: false,
error: 'Timed out waiting for Remote Control URL',
});
return;
}
setTimeout(poll, URL_POLL_MS);
};
poll();
});
}
export function stopRemoteControl(): {
ok: true;
} | { ok: false; error: string } {
if (!activeSession) {
return { ok: false, error: 'No active Remote Control session' };
}
const { pid } = activeSession;
try {
process.kill(pid, 'SIGTERM');
} catch {
// already dead
}
activeSession = null;
clearState();
logger.info({ pid }, 'Remote Control session stopped');
return { ok: true };
}