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,315 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type {
|
||||
SidecarCommand,
|
||||
SidecarEvent,
|
||||
SidecarState,
|
||||
ClaudeSpawnParams,
|
||||
ClaudeSpawnStreamingParams,
|
||||
ClaudeCodeResult,
|
||||
PiSpawnParams,
|
||||
PtyCommand,
|
||||
PtyEvent,
|
||||
} from './sidecar/protocol';
|
||||
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
||||
import type { PiEvent } from './api/pi/types';
|
||||
import type { Job, EnqueueParams } from './queue/types';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type RegisteredSidecar = {
|
||||
id: string;
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
ws: ServerWebSocket<any>;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: Timer;
|
||||
};
|
||||
|
||||
type EventHandler = (event: SidecarEvent | PtyEvent) => void;
|
||||
|
||||
// ── State ──
|
||||
|
||||
const sidecars = new Map<string, RegisteredSidecar>();
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
let cachedState: SidecarState | null = null;
|
||||
let idCounter = 0;
|
||||
|
||||
function nextId(): string {
|
||||
return `sr_${Date.now()}_${++idCounter}`;
|
||||
}
|
||||
|
||||
// ── Registration ──
|
||||
|
||||
export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
|
||||
// If a sidecar with the same name is already registered, unregister it first
|
||||
for (const [id, sc] of sidecars) {
|
||||
if (sc.name === registration.name) {
|
||||
console.log(`[registry] replacing existing sidecar "${registration.name}" (id=${id})`);
|
||||
unregisterSidecar(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const id = `sc_${registration.name}_${Date.now()}`;
|
||||
sidecars.set(id, {
|
||||
id,
|
||||
name: registration.name,
|
||||
capabilities: registration.capabilities,
|
||||
ws,
|
||||
});
|
||||
console.log(
|
||||
`[registry] registered sidecar "${registration.name}" (id=${id}, capabilities=[${registration.capabilities.join(', ')}])`,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function unregisterSidecar(id: string): void {
|
||||
const sc = sidecars.get(id);
|
||||
if (!sc) return;
|
||||
sidecars.delete(id);
|
||||
console.log(`[registry] unregistered sidecar "${sc.name}" (id=${id})`);
|
||||
|
||||
// Reject all pending requests for this sidecar
|
||||
for (const [reqId, req] of pending) {
|
||||
clearTimeout(req.timer);
|
||||
req.reject(new Error(`Sidecar "${sc.name}" disconnected`));
|
||||
pending.delete(reqId);
|
||||
}
|
||||
|
||||
// Clear cached state if the process sidecar disconnects
|
||||
if (sc.capabilities.includes('proxy')) {
|
||||
cachedState = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSidecarMessage(id: string, msg: SidecarEvent | PtyEvent): void {
|
||||
// Check if this is a response to a pending request
|
||||
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
||||
const req = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
clearTimeout(req.timer);
|
||||
req.resolve(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise dispatch as event
|
||||
dispatchEvent(msg);
|
||||
}
|
||||
|
||||
// ── Lookup ──
|
||||
|
||||
function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.capabilities.includes(cap)) return sc;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requireSidecar(cap: string): RegisteredSidecar {
|
||||
const sc = findSidecarByCapability(cap);
|
||||
if (!sc) throw new Error(`No sidecar with capability "${cap}" is connected`);
|
||||
return sc;
|
||||
}
|
||||
|
||||
// ── Event dispatch ──
|
||||
|
||||
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
|
||||
const handlers = eventHandlers.get(msg.type);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function on(eventType: string, handler: EventHandler): () => void {
|
||||
if (!eventHandlers.has(eventType)) {
|
||||
eventHandlers.set(eventType, new Set());
|
||||
}
|
||||
eventHandlers.get(eventType)!.add(handler);
|
||||
return () => {
|
||||
eventHandlers.get(eventType)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Command sending ──
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
||||
|
||||
function sendCommand(cap: string, cmd: SidecarCommand | PtyCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sc = findSidecarByCapability(cap);
|
||||
if (!sc) {
|
||||
reject(new Error(`No sidecar with capability "${cap}" is connected`));
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete((cmd as any).id);
|
||||
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set((cmd as any).id, { resolve, reject, timer });
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
});
|
||||
}
|
||||
|
||||
function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void {
|
||||
const sc = findSidecarByCapability(cap);
|
||||
if (sc) {
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API (same signatures as sidecar-client.ts) ──
|
||||
|
||||
export function isConnected(): boolean {
|
||||
return findSidecarByCapability('proxy') !== undefined;
|
||||
}
|
||||
|
||||
export function getCachedState(): SidecarState | null {
|
||||
return cachedState;
|
||||
}
|
||||
|
||||
export async function syncState(): Promise<SidecarState> {
|
||||
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
||||
if (res.type === 'state:sync') {
|
||||
cachedState = res.state;
|
||||
return res.state;
|
||||
}
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function getProxySecret(): Promise<string> {
|
||||
if (cachedState?.proxySecret) return cachedState.proxySecret;
|
||||
const res = await sendCommand('proxy', { type: 'proxy:secret', id: nextId() });
|
||||
if (res.type === 'proxy:secret') return res.secret;
|
||||
throw new Error('Failed to get proxy secret');
|
||||
}
|
||||
|
||||
export function getProxySecretSync(): string {
|
||||
return cachedState?.proxySecret ?? '';
|
||||
}
|
||||
|
||||
// ── Claude Code ──
|
||||
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const res = await sendCommand('claude', { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
||||
if (res.type === 'claude:result') return res.result;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
||||
const res = await sendCommand('claude', { type: 'claude:spawn-streaming', id: nextId(), params });
|
||||
if (res.type === 'claude:spawned') return;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function killClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function clearClaudeSession(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function onClaudeEvent(handler: (sessionKey: string, event: PiEvent) => void): () => void {
|
||||
return on('claude:event', (msg) => {
|
||||
if (msg.type === 'claude:event') {
|
||||
handler(
|
||||
(msg as SidecarEvent & { type: 'claude:event' }).sessionKey,
|
||||
(msg as SidecarEvent & { type: 'claude:event' }).event,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pi ──
|
||||
|
||||
export async function spawnPi(params: PiSpawnParams): Promise<void> {
|
||||
const res = await sendCommand('pi', { type: 'pi:spawn', id: nextId(), params });
|
||||
if (res.type === 'pi:spawned') return;
|
||||
if (res.type === 'pi:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function sendPiPrompt(sessionId: string, prompt: string, requestId: string): void {
|
||||
sendFire('pi', { type: 'pi:prompt', id: nextId(), sessionId, prompt, requestId });
|
||||
}
|
||||
|
||||
export function abortPi(sessionId: string, requestId: string): void {
|
||||
sendFire('pi', { type: 'pi:abort', id: nextId(), sessionId, requestId });
|
||||
}
|
||||
|
||||
export function killPi(sessionId: string): void {
|
||||
sendFire('pi', { type: 'pi:kill', id: nextId(), sessionId });
|
||||
}
|
||||
|
||||
export function setPiThinking(sessionId: string, level: string): void {
|
||||
sendFire('pi', { type: 'pi:set-thinking', id: nextId(), sessionId, level });
|
||||
}
|
||||
|
||||
export function onPiEvent(handler: (sessionId: string, event: PiEvent) => void): () => void {
|
||||
return on('pi:event', (msg) => {
|
||||
if (msg.type === 'pi:event') {
|
||||
handler(
|
||||
(msg as SidecarEvent & { type: 'pi:event' }).sessionId,
|
||||
(msg as SidecarEvent & { type: 'pi:event' }).event,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Queue ──
|
||||
|
||||
export async function enqueueJob(params: EnqueueParams): Promise<Job> {
|
||||
const res = await sendCommand('queue', { type: 'queue:enqueue', id: nextId(), params });
|
||||
if (res.type === 'queue:enqueued') return res.job;
|
||||
if (res.type === 'queue:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId: string): Promise<Job | null> {
|
||||
const res = await sendCommand('queue', { type: 'queue:cancel', id: nextId(), jobId });
|
||||
if (res.type === 'queue:cancelled') return res.job;
|
||||
if (res.type === 'queue:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function listJobs(): Promise<Job[]> {
|
||||
const res = await sendCommand('queue', { type: 'queue:list', id: nextId() });
|
||||
if (res.type === 'queue:list') return res.jobs;
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function getJob(jobId: string): Promise<Job | null> {
|
||||
const res = await sendCommand('queue', { type: 'queue:get', id: nextId(), jobId });
|
||||
if (res.type === 'queue:get') return res.job;
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
// ── Terminal (PTY sidecar) ──
|
||||
|
||||
export function sendPtyCommand(cmd: PtyCommand): void {
|
||||
sendFire('terminal', cmd);
|
||||
}
|
||||
|
||||
export async function sendPtyCommandAsync(cmd: PtyCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<PtyEvent> {
|
||||
return sendCommand('terminal', cmd, timeoutMs);
|
||||
}
|
||||
|
||||
export function isTerminalConnected(): boolean {
|
||||
return findSidecarByCapability('terminal') !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user