sidecar self-registration: sidecars connect to API server instead of vice versa

Flips the connection model so sidecars register themselves with the API
server via WebSocket at /api/sidecar/register, enabling dynamic discovery,
location independence, and automatic reconnection from either side.

- Add registration protocol types and PTY command/event types
- Create sidecar-registry.ts (replaces sidecar-client.ts) as passive registry
- Create sidecar connector (connect.ts) with exponential backoff reconnect
- Convert process sidecar from WS server to WS client
- Convert PTY sidecar from WS server to multiplexed WS client
- Simplify terminal bridge to thin adapter using registry
- Add PTY sidecar as PM2-managed process
- Update all consumer imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 08:48:19 +00:00
co-authored by Claude Opus 4.6
parent 6cfa40bad1
commit a0d9ea63f9
17 changed files with 878 additions and 712 deletions
+135
View File
@@ -0,0 +1,135 @@
import type { SidecarCommand, SidecarEvent, PtyCommand, PtyEvent } from './protocol';
import type { SidecarRegistration, RegistrationAck } from './registration-protocol';
type AnyCommand = SidecarCommand | PtyCommand;
type AnyEvent = SidecarEvent | PtyEvent;
type SidecarConnectorConfig = {
apiUrl: string; // ws://127.0.0.1:5000/api/sidecar/register
name: string;
capabilities: string[];
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
onConnected?: () => void;
onDisconnected?: () => void;
};
type SidecarConnection = {
send: (msg: AnyEvent) => void;
destroy: () => void;
isConnected: () => boolean;
};
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
export function createSidecarConnector(config: SidecarConnectorConfig): SidecarConnection {
let ws: WebSocket | null = null;
let connected = false;
let reconnectAttempt = 0;
let reconnectTimer: Timer | null = null;
let destroyed = false;
let sidecarId: string | null = null;
function connect() {
if (destroyed) return;
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
try {
ws = new WebSocket(config.apiUrl);
} catch {
scheduleReconnect();
return;
}
ws.onopen = () => {
reconnectAttempt = 0;
// Send registration
const registration: SidecarRegistration = {
type: 'register',
name: config.name,
capabilities: config.capabilities,
};
ws!.send(JSON.stringify(registration));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string);
// Handle registration ack
if (msg.type === 'registered') {
const ack = msg as RegistrationAck;
sidecarId = ack.id;
connected = true;
console.log(`[sidecar] registered with API server (id=${sidecarId})`);
config.onConnected?.();
return;
}
// Handle commands from API server
const reply = (response: AnyEvent) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(response));
}
};
config.onCommand(msg as AnyCommand, reply);
} catch {
// skip malformed messages
}
};
ws.onclose = () => {
const wasConnected = connected;
connected = false;
sidecarId = null;
ws = null;
if (wasConnected) {
console.log('[sidecar] disconnected from API server');
config.onDisconnected?.();
}
scheduleReconnect();
};
ws.onerror = () => {
// onclose will fire after this
};
}
function scheduleReconnect() {
if (destroyed || reconnectTimer) return;
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!;
reconnectAttempt++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
function send(msg: AnyEvent): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function destroy(): void {
destroyed = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.close();
ws = null;
}
connected = false;
}
// Start connecting
connect();
return {
send,
destroy,
isConnected: () => connected,
};
}
+39 -88
View File
@@ -1,4 +1,3 @@
import type { ServerWebSocket } from 'bun';
import type { SidecarCommand, SidecarEvent, SidecarState } from './protocol';
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
@@ -6,8 +5,9 @@ import * as claudeManager from './claude-manager';
import * as piManager from './pi-manager';
import * as queueRunner from './queue-runner';
import { initEmailCron, stopEmailCron } from './email-cron';
import { createSidecarConnector } from './connect';
const PORT = Number(process.env.SIDECAR_PORT ?? '5100');
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const startedAt = Date.now();
// ── Startup ──
@@ -35,24 +35,7 @@ queueRunner.initQueue().catch((err) => {
// Start email sync cron
// initEmailCron(); // TODO: re-enable after initial sync testing
// ── WebSocket connections ──
const clients = new Set<ServerWebSocket<unknown>>();
function broadcast(msg: SidecarEvent) {
const data = JSON.stringify(msg);
for (const ws of clients) {
if (ws.readyState === 1) {
ws.send(data);
}
}
}
function reply(ws: ServerWebSocket<unknown>, msg: SidecarEvent) {
if (ws.readyState === 1) {
ws.send(JSON.stringify(msg));
}
}
// ── State ──
function buildState(): SidecarState {
return {
@@ -65,18 +48,20 @@ function buildState(): SidecarState {
// ── Command handlers ──
async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand) {
type ReplyFn = (msg: SidecarEvent) => void;
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply(ws, { type: 'pong', id: cmd.id });
reply({ type: 'pong', id: cmd.id });
break;
case 'state:sync':
reply(ws, { type: 'state:sync', id: cmd.id, state: buildState() });
reply({ type: 'state:sync', id: cmd.id, state: buildState() });
break;
case 'proxy:secret':
reply(ws, { type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
break;
// ── Claude Code ──
@@ -84,22 +69,22 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'claude:spawn': {
try {
const result = await claudeManager.spawnClaude(cmd.params);
reply(ws, { type: 'claude:result', id: cmd.id, result });
reply({ type: 'claude:result', id: cmd.id, result });
} catch (err) {
reply(ws, { type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
case 'claude:spawn-streaming': {
reply(ws, { type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
const onEvent = (event: import('../api/pi/types').PiEvent) => {
broadcast({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
};
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
broadcast({
connection.send({
type: 'claude:event',
sessionKey: cmd.params.sessionKey,
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
@@ -110,12 +95,12 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'claude:kill':
claudeManager.killClaudeSession(cmd.sessionKey);
reply(ws, { type: 'claude:killed', id: cmd.id });
reply({ type: 'claude:killed', id: cmd.id });
break;
case 'claude:clear-session':
claudeManager.clearSession(cmd.sessionKey);
reply(ws, { type: 'claude:session-cleared', id: cmd.id });
reply({ type: 'claude:session-cleared', id: cmd.id });
break;
// ── Pi ──
@@ -123,7 +108,7 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'pi:spawn': {
try {
const onEvent = (event: import('../api/pi/types').PiEvent) => {
broadcast({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
connection.send({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
};
await piManager.spawnPi({
@@ -138,9 +123,9 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
onEvent,
});
reply(ws, { type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
reply({ type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
} catch (err) {
reply(ws, { type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
reply({ type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
@@ -155,7 +140,7 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'pi:kill':
piManager.killPiSession(cmd.sessionId);
reply(ws, { type: 'pi:killed', id: cmd.id });
reply({ type: 'pi:killed', id: cmd.id });
break;
case 'pi:set-thinking':
@@ -167,9 +152,9 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'queue:enqueue': {
try {
const job = await queueRunner.enqueue(cmd.params);
reply(ws, { type: 'queue:enqueued', id: cmd.id, job });
reply({ type: 'queue:enqueued', id: cmd.id, job });
} catch (err) {
reply(ws, { type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
reply({ type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
@@ -177,85 +162,51 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
case 'queue:cancel': {
try {
const job = await queueRunner.cancelJob(cmd.jobId);
reply(ws, { type: 'queue:cancelled', id: cmd.id, job });
reply({ type: 'queue:cancelled', id: cmd.id, job });
} catch (err) {
reply(ws, { type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
reply({ type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
case 'queue:list': {
const jobs = await queueRunner.listAllJobs();
reply(ws, { type: 'queue:list', id: cmd.id, jobs });
reply({ type: 'queue:list', id: cmd.id, jobs });
break;
}
case 'queue:get': {
const job = await queueRunner.readJob(cmd.jobId);
reply(ws, { type: 'queue:get', id: cmd.id, job });
reply({ type: 'queue:get', id: cmd.id, job });
break;
}
default:
reply(ws, { type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record<string, unknown>).type}` });
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Server ──
// ── Connect to API server ──
const server = Bun.serve({
port: PORT,
hostname: '127.0.0.1',
fetch(req, server) {
if (req.headers.get('upgrade') === 'websocket') {
const ok = server.upgrade(req);
if (!ok) return new Response('Upgrade failed', { status: 500 });
return;
}
const url = new URL(req.url);
if (url.pathname === '/') return new Response('process-sidecar');
if (url.pathname === '/health') {
return new Response(JSON.stringify({
status: 'ok',
uptime: Date.now() - startedAt,
piSessions: piManager.getAllSessions().length,
claudeSessions: claudeManager.getActiveSessionKeys().length,
}), { headers: { 'Content-Type': 'application/json' } });
}
return new Response('Not found', { status: 404 });
},
websocket: {
open(ws) {
clients.add(ws);
console.log(`[sidecar] client connected (${clients.size} total)`);
},
message(ws, raw) {
try {
const data = typeof raw === 'string' ? raw : raw.toString();
const cmd = JSON.parse(data) as SidecarCommand;
handleCommand(ws, cmd);
} catch (err) {
reply(ws, { type: 'error', error: `Invalid message: ${err instanceof Error ? err.message : String(err)}` });
}
},
close(ws) {
clients.delete(ws);
console.log(`[sidecar] client disconnected (${clients.size} total)`);
},
drain() {},
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'process',
capabilities: ['claude', 'pi', 'queue', 'proxy'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
});
console.log(`[sidecar] listening on 127.0.0.1:${PORT}`);
// ── Graceful shutdown ──
async function shutdown(signal: string) {
console.log(`[sidecar] ${signal} received, saving state...`);
stopEmailCron();
connection.destroy();
await flushAndSave();
releaseLock();
process.exit(0);
+28
View File
@@ -113,3 +113,31 @@ export type PiSpawnParams = {
model: string;
sessionFile?: string;
};
// ── PTY types ──
export type PtyInitConfig = {
sessionId: string;
shell?: { command: string; args?: string[] };
cwd?: string;
homeDir?: string;
userLabel?: string;
username?: string;
host?: boolean;
cols?: number;
rows?: number;
env?: Record<string, string>;
};
// PTY commands (API → PTY sidecar)
export type PtyCommand =
| { type: 'pty:init'; id: string; sessionId: string; config: PtyInitConfig }
| { type: 'pty:input'; id: string; sessionId: string; data: string }
| { type: 'pty:resize'; id: string; sessionId: string; cols: number; rows: number }
| { type: 'pty:close'; id: string; sessionId: string };
// PTY events (PTY sidecar → API)
export type PtyEvent =
| { type: 'pty:ready'; id: string; sessionId: string }
| { type: 'pty:output'; sessionId: string; data: string }
| { type: 'pty:exit'; sessionId: string; exitCode: number; signal?: number };
@@ -0,0 +1,16 @@
// ── Sidecar self-registration protocol ──
// Sidecar → API server on connect
export type SidecarRegistration = {
type: 'register';
name: string; // 'process' | 'pty' | custom
capabilities: string[]; // ['claude', 'pi', 'queue', 'proxy'] or ['terminal']
};
// API server → sidecar ack
export type RegistrationAck = {
type: 'registered';
id: string; // server-assigned ID
};
export type RegistrationMessage = SidecarRegistration | RegistrationAck;