Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
136 lines
3.3 KiB
TypeScript
136 lines
3.3 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from './protocol';
|
|
import type { SidecarRegistration, RegistrationAck } from './registration-protocol';
|
|
|
|
type AnyCommand = SidecarCommand;
|
|
type AnyEvent = SidecarEvent;
|
|
|
|
type SidecarConnectorConfig = {
|
|
apiUrl: string; // ws://127.0.0.1:9000/api/sidecar/register
|
|
name: string;
|
|
handles: 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,
|
|
handles: config.handles,
|
|
};
|
|
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,
|
|
};
|
|
}
|