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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user