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:
@@ -1,5 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'officer',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'start',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'officer-sidecar',
|
name: 'officer-sidecar',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
@@ -7,9 +13,9 @@ module.exports = {
|
|||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'officer',
|
name: 'officer-pty',
|
||||||
script: 'bun',
|
script: 'node',
|
||||||
args: 'start',
|
args: 'src/servers/api/terminal/pty-sidecar.mjs',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+49
-8
@@ -4,7 +4,7 @@ import { serve } from 'bun';
|
|||||||
import { honoServer } from './servers/hono';
|
import { honoServer } from './servers/hono';
|
||||||
import { verify } from './servers/jwt';
|
import { verify } from './servers/jwt';
|
||||||
import { isTokenBlacklisted } from 'officerdb';
|
import { isTokenBlacklisted } from 'officerdb';
|
||||||
import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket';
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||||
import { piWebsocket } from './servers/api/pi/websocket';
|
import { piWebsocket } from './servers/api/pi/websocket';
|
||||||
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
||||||
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
||||||
@@ -12,7 +12,8 @@ import { desktopWebsocket } from './servers/api/desktop/websocket';
|
|||||||
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||||
import officerWeb from './apps/officer-web/index.html';
|
import officerWeb from './apps/officer-web/index.html';
|
||||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||||
import { initSidecarClient } from './servers/sidecar-client';
|
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
||||||
|
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||||
import { toShellUsername } from './servers/data-path';
|
import { toShellUsername } from './servers/data-path';
|
||||||
|
|
||||||
const { PORT = '5000' } = process.env;
|
const { PORT = '5000' } = process.env;
|
||||||
@@ -22,7 +23,7 @@ type WSData = {
|
|||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop';
|
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
|
||||||
sandboxed: boolean;
|
sandboxed: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -36,12 +37,50 @@ type WSData = {
|
|||||||
wsToken?: string;
|
wsToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sidecar registration WebSocket handler
|
||||||
|
const sidecarConnections = new Map<ServerWebSocket<WSData>, string>(); // ws → sidecar ID
|
||||||
|
|
||||||
|
const sidecarWebsocket = {
|
||||||
|
open(_ws: ServerWebSocket<WSData>) {
|
||||||
|
// Wait for registration message
|
||||||
|
},
|
||||||
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||||
|
try {
|
||||||
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
|
||||||
|
if (msg.type === 'register') {
|
||||||
|
const id = registerSidecar(ws, msg as SidecarRegistration);
|
||||||
|
sidecarConnections.set(ws, id);
|
||||||
|
ws.send(JSON.stringify({ type: 'registered', id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = sidecarConnections.get(ws);
|
||||||
|
if (id) {
|
||||||
|
handleSidecarMessage(id, msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed messages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(ws: ServerWebSocket<WSData>) {
|
||||||
|
const id = sidecarConnections.get(ws);
|
||||||
|
if (id) {
|
||||||
|
unregisterSidecar(id);
|
||||||
|
sidecarConnections.delete(ws);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drain() {},
|
||||||
|
};
|
||||||
|
|
||||||
const handlers: Record<string, any> = {
|
const handlers: Record<string, any> = {
|
||||||
terminal: terminalWebsocket,
|
terminal: terminalWebsocket,
|
||||||
pi: piWebsocket,
|
pi: piWebsocket,
|
||||||
cliamp: cliampWebsocket,
|
cliamp: cliampWebsocket,
|
||||||
'cliamp-audio': cliampAudioWebsocket,
|
'cliamp-audio': cliampAudioWebsocket,
|
||||||
desktop: desktopWebsocket,
|
desktop: desktopWebsocket,
|
||||||
|
sidecar: sidecarWebsocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
|
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
|
||||||
@@ -187,6 +226,12 @@ const server = serve({
|
|||||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
||||||
return honoServer.fetch(req, server);
|
return honoServer.fetch(req, server);
|
||||||
},
|
},
|
||||||
|
'/api/sidecar/register': (req: Request, server: any) => {
|
||||||
|
const ok = server.upgrade(req, {
|
||||||
|
data: { provider: 'sidecar', userId: 0, email: '', username: '', role: '', sandboxed: false },
|
||||||
|
});
|
||||||
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
|
},
|
||||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||||
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
|
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
|
||||||
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
||||||
@@ -229,11 +274,7 @@ try {
|
|||||||
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to process sidecar (owns proxy, Claude Code, Pi, queue)
|
// Sidecars connect to us via /api/sidecar/register — no init needed
|
||||||
initSidecarClient();
|
|
||||||
|
|
||||||
|
|
||||||
void initTerminalSidecars();
|
|
||||||
|
|
||||||
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
|
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateEmailAccountStatus,
|
updateEmailAccountStatus,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
import { validateImapConnection } from './imap-validate';
|
import { validateImapConnection } from './imap-validate';
|
||||||
import * as sidecar from '../../sidecar-client';
|
import * as sidecar from '../../sidecar-registry';
|
||||||
|
|
||||||
type CreateAccountBody = {
|
type CreateAccountBody = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -137,7 +137,12 @@ accountsRouter.post('/:id/sync', async (ctx) => {
|
|||||||
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
|
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
|
||||||
|
|
||||||
// Resolve auth before enqueueing
|
// Resolve auth before enqueueing
|
||||||
const authResult = await resolveAuth(user.id, account.authType, account.email, account.credentials as Record<string, unknown>);
|
const authResult = await resolveAuth(
|
||||||
|
user.id,
|
||||||
|
account.authType,
|
||||||
|
account.email,
|
||||||
|
account.credentials as Record<string, unknown>,
|
||||||
|
);
|
||||||
if (!authResult.ok) throw BAD_REQUEST(authResult.error);
|
if (!authResult.ok) throw BAD_REQUEST(authResult.error);
|
||||||
|
|
||||||
// Set status immediately so the UI reflects the queued state
|
// Set status immediately so the UI reflects the queued state
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { sessionManager } from './session-manager';
|
|||||||
import * as storage from './storage';
|
import * as storage from './storage';
|
||||||
import * as piBridge from './pi-bridge';
|
import * as piBridge from './pi-bridge';
|
||||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||||
import * as sidecar from '@@/sidecar-client';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getHomeDirForRole } from '../../../servers/data-path';
|
import { getHomeDirForRole } from '../../../servers/data-path';
|
||||||
import { getUserSettings } from 'officerdb';
|
import { getUserSettings } from 'officerdb';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import * as sidecar from '../../sidecar-client';
|
import * as sidecar from '../../sidecar-registry';
|
||||||
import { NOT_FOUND } from '../../custom-errors';
|
import { NOT_FOUND } from '../../custom-errors';
|
||||||
|
|
||||||
export const queueRouter = createRouter();
|
export const queueRouter = createRouter();
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
// Ignore SIGINT — sudo/pty child processes may propagate it
|
// Graceful shutdown
|
||||||
process.on('SIGINT', () => {});
|
process.on('SIGINT', () => {
|
||||||
|
console.log('[pty-sidecar] shutting down...');
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
try { session.term.kill(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
sessions.clear();
|
||||||
|
if (ws) { try { ws.close(); } catch { /* ignore */ } }
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => process.emit('SIGINT'));
|
||||||
|
|
||||||
import http from 'node:http';
|
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { cp, mkdir } from 'node:fs/promises';
|
import { cp, mkdir } from 'node:fs/promises';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { WebSocketServer } from 'ws';
|
import WebSocket from 'ws';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
|
|
||||||
const run = (cmd, args, opts = {}) =>
|
const run = (cmd, args, opts = {}) =>
|
||||||
@@ -20,24 +28,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
|
|
||||||
const templateDir = join(__dirname, 'templates');
|
const templateDir = join(__dirname, 'templates');
|
||||||
|
|
||||||
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
|
import 'dotenv/config';
|
||||||
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
|
|
||||||
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
const REGISTER_URL = `${API_URL}/api/sidecar/register`;
|
||||||
|
|
||||||
const BUFFER_MAX = 50 * 1024;
|
const BUFFER_MAX = 50 * 1024;
|
||||||
|
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
|
||||||
|
|
||||||
/** @type {Map<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number, ws: import('ws').WebSocket | null, initConfig: object }>} */
|
/** @type {Map<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number, initConfig: object }>} */
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
// ── Helpers ──
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('terminal-sidecar');
|
|
||||||
});
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server });
|
|
||||||
|
|
||||||
const sendJson = (ws, msg) => {
|
const sendJson = (ws, msg) => {
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify(msg));
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -76,47 +84,26 @@ const appendBuffer = (session, data) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
// ── Command handler ──
|
||||||
let currentSessionId = null;
|
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
async function handleCommand(ws, msg) {
|
||||||
let msg;
|
switch (msg.type) {
|
||||||
try {
|
case 'pty:init': {
|
||||||
msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
const { sessionId, config } = msg;
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'init') {
|
|
||||||
const sessionId = msg.sessionId;
|
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
currentSessionId = sessionId;
|
|
||||||
const existing = sessions.get(sessionId);
|
const existing = sessions.get(sessionId);
|
||||||
|
console.log(`[pty-sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
|
||||||
console.log(`[sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Evict old WS if still attached
|
|
||||||
if (existing.ws && existing.ws !== ws) {
|
|
||||||
sendJson(existing.ws, { type: 'detached' });
|
|
||||||
try {
|
|
||||||
existing.ws.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.ws = ws;
|
|
||||||
|
|
||||||
// Replay buffer
|
// Replay buffer
|
||||||
if (existing.buffer.length > 0) {
|
if (existing.buffer.length > 0) {
|
||||||
sendJson(ws, { type: 'output', data: existing.buffer });
|
sendJson(ws, { type: 'pty:output', sessionId, data: existing.buffer });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize PTY to new client dimensions
|
// Resize PTY to new client dimensions
|
||||||
const cols = msg.cols ?? existing.cols;
|
const cols = config.cols ?? existing.cols;
|
||||||
const rows = msg.rows ?? existing.rows;
|
const rows = config.rows ?? existing.rows;
|
||||||
if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) {
|
if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) {
|
||||||
existing.cols = cols;
|
existing.cols = cols;
|
||||||
existing.rows = rows;
|
existing.rows = rows;
|
||||||
@@ -127,44 +114,40 @@ wss.on('connection', (ws) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// New session — spawn PTY
|
// New session — spawn PTY
|
||||||
const shell = msg.shell ?? { command: '/bin/bash', args: ['-i'] };
|
const shell = config.shell ?? { command: '/bin/bash', args: ['-i'] };
|
||||||
const cwd = msg.cwd ?? process.cwd();
|
const cwd = config.cwd ?? process.cwd();
|
||||||
const homeDir = msg.homeDir ?? process.cwd();
|
const homeDir = config.homeDir ?? process.cwd();
|
||||||
const userLabel = msg.userLabel ?? 'officer';
|
const userLabel = config.userLabel ?? 'officer';
|
||||||
const username = msg.username ?? null;
|
const username = config.username ?? null;
|
||||||
const cols = msg.cols ?? 80;
|
const cols = config.cols ?? 80;
|
||||||
const rows = msg.rows ?? 24;
|
const rows = config.rows ?? 24;
|
||||||
const isHost = !!msg.host;
|
const isHost = !!config.host;
|
||||||
|
|
||||||
let spawnCommand;
|
let spawnCommand;
|
||||||
let spawnArgs;
|
let spawnArgs;
|
||||||
let ptyEnv;
|
let ptyEnv;
|
||||||
|
|
||||||
if (isHost) {
|
if (isHost) {
|
||||||
// Host session — spawn shell directly as current user
|
|
||||||
spawnCommand = shell.command;
|
spawnCommand = shell.command;
|
||||||
spawnArgs = shell.args ?? [];
|
spawnArgs = shell.args ?? [];
|
||||||
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(msg.env ?? {}) };
|
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) };
|
||||||
} else if (username) {
|
} else if (username) {
|
||||||
// User session — spawn via sudo -u as the target Linux user
|
|
||||||
spawnCommand = 'sudo';
|
spawnCommand = 'sudo';
|
||||||
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
|
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
|
||||||
ptyEnv = {
|
ptyEnv = { TERM: 'xterm-256color' };
|
||||||
TERM: 'xterm-256color',
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback — direct spawn with custom env (legacy)
|
|
||||||
spawnCommand = shell.command;
|
spawnCommand = shell.command;
|
||||||
spawnArgs = shell.args ?? [];
|
spawnArgs = shell.args ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureUserFiles(homeDir);
|
await ensureUserFiles(homeDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[sidecar] ensureUserFiles failed:', err);
|
console.error('[pty-sidecar] ensureUserFiles failed:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
ptyEnv = {
|
ptyEnv = {
|
||||||
@@ -180,8 +163,6 @@ wss.on('connection', (ws) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For username sessions, don't set cwd — sudo -u -i will cd to the user's home.
|
|
||||||
// node-pty does chdir before exec, so it would fail if the service user can't access the dir.
|
|
||||||
const ptyCwd = username ? undefined : cwd;
|
const ptyCwd = username ? undefined : cwd;
|
||||||
|
|
||||||
let term;
|
let term;
|
||||||
@@ -195,8 +176,8 @@ wss.on('connection', (ws) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||||
sendJson(ws, { type: 'output', data: `\r\n[Terminal error] ${message}\r\n` });
|
sendJson(ws, { type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
|
||||||
sendJson(ws, { type: 'exit' });
|
sendJson(ws, { type: 'pty:exit', sessionId, exitCode: 1 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,66 +186,125 @@ wss.on('connection', (ws) => {
|
|||||||
buffer: '',
|
buffer: '',
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
ws,
|
|
||||||
initConfig: { shell, cwd, homeDir, userLabel },
|
initConfig: { shell, cwd, homeDir, userLabel },
|
||||||
};
|
};
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
term.onData((output) => {
|
term.onData((output) => {
|
||||||
appendBuffer(session, output);
|
appendBuffer(session, output);
|
||||||
if (session.ws) {
|
sendJson(ws, { type: 'pty:output', sessionId, data: output });
|
||||||
sendJson(session.ws, { type: 'output', data: output });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
term.onExit(({ exitCode, signal }) => {
|
term.onExit(({ exitCode, signal }) => {
|
||||||
console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
||||||
if (session.ws) {
|
sendJson(ws, { type: 'pty:exit', sessionId, exitCode, signal });
|
||||||
sendJson(session.ws, { type: 'exit' });
|
|
||||||
}
|
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route other messages to current session
|
case 'pty:input': {
|
||||||
if (!currentSessionId) return;
|
const session = sessions.get(msg.sessionId);
|
||||||
const session = sessions.get(currentSessionId);
|
if (session) {
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'input':
|
|
||||||
session.term.write(msg.data ?? '');
|
session.term.write(msg.data ?? '');
|
||||||
break;
|
}
|
||||||
case 'resize':
|
break;
|
||||||
if (msg.cols > 0 && msg.rows > 0) {
|
}
|
||||||
session.cols = msg.cols;
|
|
||||||
session.rows = msg.rows;
|
case 'pty:resize': {
|
||||||
try {
|
const session = sessions.get(msg.sessionId);
|
||||||
session.term.resize(msg.cols, msg.rows);
|
if (session && msg.cols > 0 && msg.rows > 0) {
|
||||||
} catch {
|
session.cols = msg.cols;
|
||||||
// PTY may have already exited
|
session.rows = msg.rows;
|
||||||
}
|
try {
|
||||||
|
session.term.resize(msg.cols, msg.rows);
|
||||||
|
} catch {
|
||||||
|
// PTY may have already exited
|
||||||
}
|
}
|
||||||
break;
|
}
|
||||||
case 'cwd':
|
break;
|
||||||
if (msg.path) session.term.write(`cd ${JSON.stringify(msg.path)}\r`);
|
}
|
||||||
break;
|
|
||||||
|
case 'pty:close': {
|
||||||
|
const session = sessions.get(msg.sessionId);
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
session.term.kill();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
sessions.delete(msg.sessionId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connect to API server with reconnect ──
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
console.log(`[pty-sidecar] starting, connecting to ${REGISTER_URL}`);
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(REGISTER_URL);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[pty-sidecar] failed to create WebSocket:`, err.message ?? err);
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
console.log('[pty-sidecar] connected, sending registration...');
|
||||||
|
sendJson(ws, { type: 'register', name: 'pty', capabilities: ['terminal'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
||||||
|
|
||||||
|
if (msg.type === 'registered') {
|
||||||
|
console.log(`[pty-sidecar] registered with API server (id=${msg.id})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommand(ws, msg);
|
||||||
|
} catch {
|
||||||
|
// skip malformed messages
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
// Detach WS from session — do NOT kill PTY
|
console.log('[pty-sidecar] disconnected from API server');
|
||||||
if (currentSessionId) {
|
ws = null;
|
||||||
const session = sessions.get(currentSessionId);
|
scheduleReconnect();
|
||||||
if (session && session.ws === ws) {
|
|
||||||
session.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, host, () => {
|
ws.on('error', (err) => {
|
||||||
console.log(`[terminal-sidecar] listening on ${host}:${port}`);
|
if (reconnectAttempt <= 1) {
|
||||||
});
|
console.error(`[pty-sidecar] connection error: ${err.message ?? err}`);
|
||||||
|
}
|
||||||
|
// onclose will fire after this
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) return;
|
||||||
|
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
||||||
|
reconnectAttempt++;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connecting
|
||||||
|
connect();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
|
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
||||||
|
import type { PtyInitConfig } from '../../sidecar/protocol';
|
||||||
|
|
||||||
type WSData = {
|
type WSData = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -15,17 +16,20 @@ type WSData = {
|
|||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BridgeSession = {
|
type BridgeSession = {
|
||||||
client: ServerWebSocket<WSData>;
|
client: ServerWebSocket<WSData>;
|
||||||
sidecar: WebSocket | null;
|
sessionId: string;
|
||||||
pendingMessages: string[];
|
unsubOutput: (() => void) | null;
|
||||||
|
unsubExit: (() => void) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HOST_SIDECAR_PORT = 5338;
|
|
||||||
|
|
||||||
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
||||||
|
|
||||||
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
|
let idCounter = 0;
|
||||||
|
function nextId(): string {
|
||||||
|
return `pty_${Date.now()}_${++idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -35,105 +39,6 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectSidecar = async (): Promise<WebSocket> => {
|
|
||||||
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (const delay of delays) {
|
|
||||||
try {
|
|
||||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
||||||
const socket = new WebSocket(`ws://127.0.0.1:${HOST_SIDECAR_PORT}`);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
socket.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
reject(new Error('Terminal sidecar timeout'));
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(socket);
|
|
||||||
});
|
|
||||||
socket.addEventListener('error', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(new Error('Terminal sidecar connection failed'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err instanceof Error ? err : new Error('Terminal sidecar connection failed');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError ?? new Error('Terminal sidecar connection failed');
|
|
||||||
};
|
|
||||||
|
|
||||||
const sidecarAlive = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://127.0.0.1:${HOST_SIDECAR_PORT}`, { signal: AbortSignal.timeout(500) });
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const killSidecarOnPort = (port: number) => {
|
|
||||||
try {
|
|
||||||
const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' });
|
|
||||||
if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`);
|
|
||||||
} catch {
|
|
||||||
// fuser not available or failed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startHostSidecar = async () => {
|
|
||||||
if (hostSidecarProcess) {
|
|
||||||
hostSidecarProcess.kill();
|
|
||||||
await hostSidecarProcess.exited.catch(() => {});
|
|
||||||
hostSidecarProcess = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
killSidecarOnPort(HOST_SIDECAR_PORT);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
|
|
||||||
hostSidecarProcess = Bun.spawn({
|
|
||||||
cmd: ['node', sidecarPath],
|
|
||||||
env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) },
|
|
||||||
stdout: 'inherit',
|
|
||||||
stderr: 'inherit',
|
|
||||||
});
|
|
||||||
console.log(`[terminal] host sidecar started on port ${HOST_SIDECAR_PORT}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ensureHostSidecar = async () => {
|
|
||||||
const alive = await sidecarAlive();
|
|
||||||
if (!alive) {
|
|
||||||
await startHostSidecar();
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
|
||||||
if (await sidecarAlive()) return;
|
|
||||||
}
|
|
||||||
throw new Error('Host sidecar failed to start');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initTerminalSidecars = async () => {
|
|
||||||
// Sidecar is managed by pm2 — wait for it to be available
|
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
if (await sidecarAlive()) {
|
|
||||||
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
console.warn(`[terminal] host sidecar not detected on port ${HOST_SIDECAR_PORT} — terminals will retry on connect`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveCwd = (home: string, cwd?: string) => {
|
const resolveCwd = (home: string, cwd?: string) => {
|
||||||
if (!cwd || cwd === '~') return home;
|
if (!cwd || cwd === '~') return home;
|
||||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||||
@@ -150,102 +55,126 @@ export const terminalWebsocket = {
|
|||||||
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const session: BridgeSession = { client: ws, sidecar: null, pendingMessages: [] };
|
if (!isTerminalConnected()) {
|
||||||
sessions.set(ws, session);
|
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
|
||||||
|
|
||||||
let sidecar: WebSocket | null = null;
|
|
||||||
try {
|
|
||||||
sidecar = await connectSidecar();
|
|
||||||
console.log('[terminal] sidecar connected');
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
|
||||||
console.error('[terminal] sidecar connection failed:', message);
|
|
||||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
|
||||||
sessions.delete(ws);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.sidecar = sidecar;
|
const sessionId = ws.data.sessionId ?? (isHost ? `host-${ws.data.userId}` : `default-${ws.data.userId}`);
|
||||||
|
|
||||||
sidecar.addEventListener('message', (ev) => {
|
// Build PTY init config
|
||||||
try {
|
let config: PtyInitConfig;
|
||||||
if (typeof ev.data === 'string') {
|
|
||||||
ws.send(ev.data);
|
|
||||||
} else {
|
|
||||||
ws.send(new TextDecoder().decode(ev.data));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ws already closed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isHost) {
|
if (isHost) {
|
||||||
// Super Admin host terminal — spawn as the service user directly
|
config = {
|
||||||
sidecar.send(
|
sessionId,
|
||||||
JSON.stringify({
|
host: true,
|
||||||
type: 'init',
|
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||||
host: true,
|
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
homeDir: process.env.HOME!,
|
||||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
userLabel: email,
|
||||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
cols: ws.data.cols,
|
||||||
homeDir: process.env.HOME,
|
rows: ws.data.rows,
|
||||||
userLabel: email,
|
};
|
||||||
cols: ws.data.cols,
|
|
||||||
rows: ws.data.rows,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// User terminal — spawn as the target Linux user via sudo -u
|
|
||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
mkdirSync(homeDir, { recursive: true });
|
mkdirSync(homeDir, { recursive: true });
|
||||||
|
|
||||||
sidecar.send(
|
config = {
|
||||||
JSON.stringify({
|
sessionId,
|
||||||
type: 'init',
|
username,
|
||||||
username,
|
cwd: resolveCwd(homeDir, ws.data.cwd),
|
||||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
homeDir,
|
||||||
cwd: resolveCwd(homeDir, ws.data.cwd),
|
userLabel: email,
|
||||||
homeDir,
|
cols: ws.data.cols,
|
||||||
userLabel: email,
|
rows: ws.data.rows,
|
||||||
cols: ws.data.cols,
|
};
|
||||||
rows: ws.data.rows,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
// Subscribe to events for this session
|
||||||
session.pendingMessages = [];
|
const unsubOutput = on('pty:output', (msg) => {
|
||||||
|
if (msg.type === 'pty:output' && msg.sessionId === sessionId) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'output', data: msg.data }));
|
||||||
|
} catch {
|
||||||
|
// ws already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubExit = on('pty:exit', (msg) => {
|
||||||
|
if (msg.type === 'pty:exit' && msg.sessionId === sessionId) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'exit' }));
|
||||||
|
} catch {
|
||||||
|
// ws already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: BridgeSession = { client: ws, sessionId, unsubOutput, unsubExit };
|
||||||
|
sessions.set(ws, session);
|
||||||
|
|
||||||
|
// Send init command to PTY sidecar
|
||||||
|
try {
|
||||||
|
await sendPtyCommandAsync({ type: 'pty:init', id: nextId(), sessionId, config });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to initialize terminal';
|
||||||
|
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||||
|
unsubOutput();
|
||||||
|
unsubExit();
|
||||||
|
sessions.delete(ws);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||||
const session = sessions.get(ws);
|
const session = sessions.get(ws);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
|
||||||
|
|
||||||
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
|
|
||||||
session.pendingMessages.push(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session.sidecar.send(payload);
|
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||||
|
const msg = JSON.parse(payload);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'input':
|
||||||
|
sendPtyCommand({ type: 'pty:input', id: nextId(), sessionId: session.sessionId, data: msg.data ?? '' });
|
||||||
|
break;
|
||||||
|
case 'resize':
|
||||||
|
if (msg.cols > 0 && msg.rows > 0) {
|
||||||
|
sendPtyCommand({
|
||||||
|
type: 'pty:resize',
|
||||||
|
id: nextId(),
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
cols: msg.cols,
|
||||||
|
rows: msg.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'cwd':
|
||||||
|
if (msg.path) {
|
||||||
|
sendPtyCommand({
|
||||||
|
type: 'pty:input',
|
||||||
|
id: nextId(),
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
data: `cd ${JSON.stringify(msg.path)}\r`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
close(ws: ServerWebSocket<WSData>) {
|
close(ws: ServerWebSocket<WSData>) {
|
||||||
const session = sessions.get(ws);
|
const session = sessions.get(ws);
|
||||||
if (session?.sidecar) {
|
if (session) {
|
||||||
try {
|
session.unsubOutput?.();
|
||||||
session.sidecar.close();
|
session.unsubExit?.();
|
||||||
} catch {
|
// Don't kill PTY — it can be reattached
|
||||||
// ignore
|
sessions.delete(ws);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sessions.delete(ws);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
drain() {},
|
drain() {},
|
||||||
@@ -253,8 +182,8 @@ export const terminalWebsocket = {
|
|||||||
|
|
||||||
export const broadcastPanelRefresh = (email: string) => {
|
export const broadcastPanelRefresh = (email: string) => {
|
||||||
const msg = JSON.stringify({ type: 'panel-refresh' });
|
const msg = JSON.stringify({ type: 'panel-refresh' });
|
||||||
for (const [ws, session] of sessions) {
|
for (const [ws] of sessions) {
|
||||||
if (ws.data.email === email && session.sidecar) {
|
if (ws.data.email === email) {
|
||||||
try {
|
try {
|
||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -263,12 +192,3 @@ export const broadcastPanelRefresh = (email: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopAllSidecars = async () => {
|
|
||||||
if (hostSidecarProcess) {
|
|
||||||
hostSidecarProcess.kill();
|
|
||||||
await hostSidecarProcess.exited.catch(() => {});
|
|
||||||
hostSidecarProcess = null;
|
|
||||||
console.log('[terminal] host sidecar stopped');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
|
|||||||
import { consumePairingCode } from '../pairing';
|
import { consumePairingCode } from '../pairing';
|
||||||
import { chunkMessage } from './chunker';
|
import { chunkMessage } from './chunker';
|
||||||
import { listPiModels } from '@@/api/pi/list-models';
|
import { listPiModels } from '@@/api/pi/list-models';
|
||||||
import { enqueueJob } from '../../sidecar-client';
|
import { enqueueJob } from '../../sidecar-registry';
|
||||||
import { readJob } from '@@/queue/storage';
|
import { readJob } from '@@/queue/storage';
|
||||||
import { openEmailDb } from '@@/api/email/email-db';
|
import { openEmailDb } from '@@/api/email/email-db';
|
||||||
import type { ModelInfo } from '@@/api/pi/types';
|
import type { ModelInfo } from '@@/api/pi/types';
|
||||||
@@ -95,9 +95,9 @@ async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newest = db.query(
|
const newest = db
|
||||||
'SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?',
|
.query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?')
|
||||||
).all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
.all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const lines = newest.map((e) => {
|
const lines = newest.map((e) => {
|
||||||
@@ -128,9 +128,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
|||||||
'`!model` — show current model\n' +
|
'`!model` — show current model\n' +
|
||||||
'`!model <id>` — switch model\n' +
|
'`!model <id>` — switch model\n' +
|
||||||
'`!models` — list available models',
|
'`!models` — list available models',
|
||||||
email:
|
email: '**Email:**\n' + '`!email sync` — sync Gmail and show new emails',
|
||||||
'**Email:**\n' +
|
|
||||||
'`!email sync` — sync Gmail and show new emails',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (lower === '!help' || lower.startsWith('!help ')) {
|
if (lower === '!help' || lower.startsWith('!help ')) {
|
||||||
@@ -140,7 +138,11 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (topic) {
|
if (topic) {
|
||||||
await channel.send(`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections).map((k) => `\`${k}\``).join(', ')}`);
|
await channel.send(
|
||||||
|
`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections)
|
||||||
|
.map((k) => `\`${k}\``)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const full = Object.values(helpSections).join('\n\n');
|
const full = Object.values(helpSections).join('\n\n');
|
||||||
@@ -172,7 +174,11 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
|||||||
|
|
||||||
if (lower === '!model') {
|
if (lower === '!model') {
|
||||||
const current = getSessionModel('discord', ctx.userId, discordId);
|
const current = getSessionModel('discord', ctx.userId, discordId);
|
||||||
await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.');
|
await channel.send(
|
||||||
|
current
|
||||||
|
? `Current model: **${current}**`
|
||||||
|
: 'No active session yet — the default model will be used on your next message.',
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,17 +235,23 @@ export async function handleDiscordMessage(message: DiscordMessage): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
await channel.send(
|
await channel.send(
|
||||||
'I don\'t recognize your Discord account. To link it:\n' +
|
"I don't recognize your Discord account. To link it:\n" +
|
||||||
'1. Go to Officer Settings → Integrations → Discord\n' +
|
'1. Go to Officer Settings → Integrations → Discord\n' +
|
||||||
'2. Click "Link Discord" to get a pairing code\n' +
|
'2. Click "Link Discord" to get a pairing code\n' +
|
||||||
'3. Send the 6-character code to me here',
|
'3. Send the 6-character code to me here',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle commands
|
// Handle commands
|
||||||
if (content.startsWith('!')) {
|
if (content.startsWith('!')) {
|
||||||
const handled = await handleCommand({ content, channel, userId: linked.user.id, email: linked.user.email, discordId });
|
const handled = await handleCommand({
|
||||||
|
content,
|
||||||
|
channel,
|
||||||
|
userId: linked.user.id,
|
||||||
|
email: linked.user.email,
|
||||||
|
discordId,
|
||||||
|
});
|
||||||
if (handled) return;
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { logger } from '@@/api/pi/logger';
|
import { logger } from '@@/api/pi/logger';
|
||||||
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
||||||
import * as sidecar from '@@/sidecar-client';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
|
|
||||||
type ClaudeCodeParams = {
|
type ClaudeCodeParams = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { consumePairingCode } from '../pairing';
|
|||||||
import { chunkMessage } from './chunker';
|
import { chunkMessage } from './chunker';
|
||||||
import { getTelegramBot } from './bot';
|
import { getTelegramBot } from './bot';
|
||||||
import { listPiModels } from '@@/api/pi/list-models';
|
import { listPiModels } from '@@/api/pi/list-models';
|
||||||
import { enqueueJob } from '../../sidecar-client';
|
import { enqueueJob } from '../../sidecar-registry';
|
||||||
import { readJob } from '@@/queue/storage';
|
import { readJob } from '@@/queue/storage';
|
||||||
import { openEmailDb } from '@@/api/email/email-db';
|
import { openEmailDb } from '@@/api/email/email-db';
|
||||||
import type { ModelInfo } from '@@/api/pi/types';
|
import type { ModelInfo } from '@@/api/pi/types';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
|
|||||||
import { consumePairingCode } from '../pairing';
|
import { consumePairingCode } from '../pairing';
|
||||||
import { getWhatsAppClient } from './bot';
|
import { getWhatsAppClient } from './bot';
|
||||||
import { listPiModels } from '@@/api/pi/list-models';
|
import { listPiModels } from '@@/api/pi/list-models';
|
||||||
import { enqueueJob } from '../../sidecar-client';
|
import { enqueueJob } from '../../sidecar-registry';
|
||||||
import { readJob } from '@@/queue/storage';
|
import { readJob } from '@@/queue/storage';
|
||||||
import { openEmailDb } from '@@/api/email/email-db';
|
import { openEmailDb } from '@@/api/email/email-db';
|
||||||
import type { ModelInfo } from '@@/api/pi/types';
|
import type { ModelInfo } from '@@/api/pi/types';
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import type {
|
|
||||||
SidecarCommand,
|
|
||||||
SidecarEvent,
|
|
||||||
SidecarState,
|
|
||||||
ClaudeSpawnParams,
|
|
||||||
ClaudeSpawnStreamingParams,
|
|
||||||
ClaudeCodeResult,
|
|
||||||
PiSpawnParams,
|
|
||||||
} from './sidecar/protocol';
|
|
||||||
import type { PiEvent } from './api/pi/types';
|
|
||||||
import type { Job, EnqueueParams } from './queue/types';
|
|
||||||
|
|
||||||
const SIDECAR_PORT = Number(process.env.SIDECAR_PORT ?? '5100');
|
|
||||||
const SIDECAR_URL = `ws://127.0.0.1:${SIDECAR_PORT}`;
|
|
||||||
|
|
||||||
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
|
|
||||||
|
|
||||||
type PendingRequest = {
|
|
||||||
resolve: (value: SidecarEvent) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
timer: Timer;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EventHandler = (event: SidecarEvent) => void;
|
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
let connected = false;
|
|
||||||
let reconnectAttempt = 0;
|
|
||||||
let reconnectTimer: Timer | null = null;
|
|
||||||
const pending = new Map<string, PendingRequest>();
|
|
||||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
|
||||||
let cachedState: SidecarState | null = null;
|
|
||||||
|
|
||||||
// ── Connection management ──
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(SIDECAR_URL);
|
|
||||||
} catch {
|
|
||||||
scheduleReconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
connected = true;
|
|
||||||
reconnectAttempt = 0;
|
|
||||||
console.log('[sidecar-client] connected');
|
|
||||||
|
|
||||||
// Sync state on connect
|
|
||||||
sendCommand({ type: 'state:sync', id: nextId() }).then((res) => {
|
|
||||||
if (res.type === 'state:sync') {
|
|
||||||
cachedState = res.state;
|
|
||||||
console.log('[sidecar-client] state synced');
|
|
||||||
}
|
|
||||||
}).catch(() => { /* best effort */ });
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data as string) as SidecarEvent;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch {
|
|
||||||
// skip malformed messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
connected = false;
|
|
||||||
ws = null;
|
|
||||||
rejectAllPending('WebSocket disconnected');
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
// onclose will fire after this
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimer) return;
|
|
||||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!;
|
|
||||||
reconnectAttempt++;
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connect();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectAllPending(reason: string) {
|
|
||||||
for (const [id, req] of pending) {
|
|
||||||
clearTimeout(req.timer);
|
|
||||||
req.reject(new Error(reason));
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event dispatch ──
|
|
||||||
|
|
||||||
function dispatchEvent(msg: SidecarEvent) {
|
|
||||||
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 ──
|
|
||||||
|
|
||||||
let idCounter = 0;
|
|
||||||
function nextId(): string {
|
|
||||||
return `sc_${Date.now()}_${++idCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
||||||
const LONG_TIMEOUT_MS = 6 * 60 * 1000; // 6 min for claude spawn
|
|
||||||
|
|
||||||
function sendCommand(cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<SidecarEvent> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
||||||
reject(new Error('Sidecar not connected'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pending.delete(cmd.id);
|
|
||||||
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
pending.set(cmd.id, { resolve, reject, timer });
|
|
||||||
ws.send(JSON.stringify(cmd));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendFire(cmd: SidecarCommand): void {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify(cmd));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ──
|
|
||||||
|
|
||||||
export function isConnected(): boolean {
|
|
||||||
return connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCachedState(): SidecarState | null {
|
|
||||||
return cachedState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncState(): Promise<SidecarState> {
|
|
||||||
const res = await sendCommand({ 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({ 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({ 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({ 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({ type: 'claude:kill', id: nextId(), sessionKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearClaudeSession(sessionKey: string): void {
|
|
||||||
sendFire({ 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.sessionKey, msg.event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pi ──
|
|
||||||
|
|
||||||
export async function spawnPi(params: PiSpawnParams): Promise<void> {
|
|
||||||
const res = await sendCommand({ 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({ type: 'pi:prompt', id: nextId(), sessionId, prompt, requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function abortPi(sessionId: string, requestId: string): void {
|
|
||||||
sendFire({ type: 'pi:abort', id: nextId(), sessionId, requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function killPi(sessionId: string): void {
|
|
||||||
sendFire({ type: 'pi:kill', id: nextId(), sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setPiThinking(sessionId: string, level: string): void {
|
|
||||||
sendFire({ 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.sessionId, msg.event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Queue ──
|
|
||||||
|
|
||||||
export async function enqueueJob(params: EnqueueParams): Promise<Job> {
|
|
||||||
const res = await sendCommand({ 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({ 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({ 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({ type: 'queue:get', id: nextId(), jobId });
|
|
||||||
if (res.type === 'queue:get') return res.job;
|
|
||||||
throw new Error('Unexpected response');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health check ──
|
|
||||||
|
|
||||||
export async function isSidecarAlive(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://127.0.0.1:${SIDECAR_PORT}/`, { signal: AbortSignal.timeout(1000) });
|
|
||||||
const text = await res.text();
|
|
||||||
return text === 'process-sidecar';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init ──
|
|
||||||
|
|
||||||
export function initSidecarClient(): void {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 type { SidecarCommand, SidecarEvent, SidecarState } from './protocol';
|
||||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||||
@@ -6,8 +5,9 @@ import * as claudeManager from './claude-manager';
|
|||||||
import * as piManager from './pi-manager';
|
import * as piManager from './pi-manager';
|
||||||
import * as queueRunner from './queue-runner';
|
import * as queueRunner from './queue-runner';
|
||||||
import { initEmailCron, stopEmailCron } from './email-cron';
|
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();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
// ── Startup ──
|
// ── Startup ──
|
||||||
@@ -35,24 +35,7 @@ queueRunner.initQueue().catch((err) => {
|
|||||||
// Start email sync cron
|
// Start email sync cron
|
||||||
// initEmailCron(); // TODO: re-enable after initial sync testing
|
// initEmailCron(); // TODO: re-enable after initial sync testing
|
||||||
|
|
||||||
// ── WebSocket connections ──
|
// ── State ──
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildState(): SidecarState {
|
function buildState(): SidecarState {
|
||||||
return {
|
return {
|
||||||
@@ -65,18 +48,20 @@ function buildState(): SidecarState {
|
|||||||
|
|
||||||
// ── Command handlers ──
|
// ── 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) {
|
switch (cmd.type) {
|
||||||
case 'ping':
|
case 'ping':
|
||||||
reply(ws, { type: 'pong', id: cmd.id });
|
reply({ type: 'pong', id: cmd.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'state:sync':
|
case 'state:sync':
|
||||||
reply(ws, { type: 'state:sync', id: cmd.id, state: buildState() });
|
reply({ type: 'state:sync', id: cmd.id, state: buildState() });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'proxy:secret':
|
case 'proxy:secret':
|
||||||
reply(ws, { type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Claude Code ──
|
// ── Claude Code ──
|
||||||
@@ -84,22 +69,22 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
case 'claude:spawn': {
|
case 'claude:spawn': {
|
||||||
try {
|
try {
|
||||||
const result = await claudeManager.spawnClaude(cmd.params);
|
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) {
|
} 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'claude:spawn-streaming': {
|
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) => {
|
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) => {
|
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||||
broadcast({
|
connection.send({
|
||||||
type: 'claude:event',
|
type: 'claude:event',
|
||||||
sessionKey: cmd.params.sessionKey,
|
sessionKey: cmd.params.sessionKey,
|
||||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
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':
|
case 'claude:kill':
|
||||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||||
reply(ws, { type: 'claude:killed', id: cmd.id });
|
reply({ type: 'claude:killed', id: cmd.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'claude:clear-session':
|
case 'claude:clear-session':
|
||||||
claudeManager.clearSession(cmd.sessionKey);
|
claudeManager.clearSession(cmd.sessionKey);
|
||||||
reply(ws, { type: 'claude:session-cleared', id: cmd.id });
|
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Pi ──
|
// ── Pi ──
|
||||||
@@ -123,7 +108,7 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
case 'pi:spawn': {
|
case 'pi:spawn': {
|
||||||
try {
|
try {
|
||||||
const onEvent = (event: import('../api/pi/types').PiEvent) => {
|
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({
|
await piManager.spawnPi({
|
||||||
@@ -138,9 +123,9 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
onEvent,
|
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) {
|
} 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -155,7 +140,7 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
|
|
||||||
case 'pi:kill':
|
case 'pi:kill':
|
||||||
piManager.killPiSession(cmd.sessionId);
|
piManager.killPiSession(cmd.sessionId);
|
||||||
reply(ws, { type: 'pi:killed', id: cmd.id });
|
reply({ type: 'pi:killed', id: cmd.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'pi:set-thinking':
|
case 'pi:set-thinking':
|
||||||
@@ -167,9 +152,9 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
case 'queue:enqueue': {
|
case 'queue:enqueue': {
|
||||||
try {
|
try {
|
||||||
const job = await queueRunner.enqueue(cmd.params);
|
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) {
|
} 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -177,85 +162,51 @@ async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand)
|
|||||||
case 'queue:cancel': {
|
case 'queue:cancel': {
|
||||||
try {
|
try {
|
||||||
const job = await queueRunner.cancelJob(cmd.jobId);
|
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) {
|
} 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'queue:list': {
|
case 'queue:list': {
|
||||||
const jobs = await queueRunner.listAllJobs();
|
const jobs = await queueRunner.listAllJobs();
|
||||||
reply(ws, { type: 'queue:list', id: cmd.id, jobs });
|
reply({ type: 'queue:list', id: cmd.id, jobs });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'queue:get': {
|
case 'queue:get': {
|
||||||
const job = await queueRunner.readJob(cmd.jobId);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
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({
|
const connection = createSidecarConnector({
|
||||||
port: PORT,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
hostname: '127.0.0.1',
|
name: 'process',
|
||||||
|
capabilities: ['claude', 'pi', 'queue', 'proxy'],
|
||||||
fetch(req, server) {
|
onCommand(cmd, reply) {
|
||||||
if (req.headers.get('upgrade') === 'websocket') {
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
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() {},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[sidecar] listening on 127.0.0.1:${PORT}`);
|
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
// ── Graceful shutdown ──
|
||||||
|
|
||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
console.log(`[sidecar] ${signal} received, saving state...`);
|
console.log(`[sidecar] ${signal} received, saving state...`);
|
||||||
stopEmailCron();
|
stopEmailCron();
|
||||||
|
connection.destroy();
|
||||||
await flushAndSave();
|
await flushAndSave();
|
||||||
releaseLock();
|
releaseLock();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -113,3 +113,31 @@ export type PiSpawnParams = {
|
|||||||
model: string;
|
model: string;
|
||||||
sessionFile?: 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