feat(gateway): add WebSocket gateway with JSON-RPC protocol and auth

Phase 2 of the Flynn roadmap. Adds a WebSocket gateway server that
starts alongside the Telegram bot, providing real-time API access to
the agent, sessions, and tools.

Protocol: JSON-RPC-like (request/response/event) over WebSocket.
8 methods: agent.send, agent.cancel, sessions.list, sessions.history,
sessions.create, tools.list, tools.invoke, system.health.

Auth: Bearer token + Tailscale identity header support.
Session bridge: per-connection agent instances with shared model router.

New files: src/gateway/ (protocol, router, server, auth, session-bridge,
handlers for agent/sessions/tools/system).
57 new tests (181 total), typecheck clean.
This commit is contained in:
William Valentin
2026-02-05 19:11:25 -08:00
parent ad7fc241f1
commit f30a8bc318
21 changed files with 1878 additions and 2 deletions
+59
View File
@@ -0,0 +1,59 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
import type { SessionManager } from '../../session/manager.js';
export interface SessionHandlerDeps {
sessionManager: SessionManager;
}
export function createSessionHandlers(deps: SessionHandlerDeps) {
return {
'sessions.list': async (request: GatewayRequest): Promise<OutboundMessage> => {
const sessionIds = deps.sessionManager.listSessions();
const sessions = sessionIds.map(id => ({
id,
messageCount: deps.sessionManager.getSession(
id.split(':')[0],
id.split(':').slice(1).join(':')
).getHistory().length,
}));
return makeResponse(request.id, { sessions });
},
'sessions.history': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; limit?: number; offset?: number } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const { sessionId, limit, offset } = params;
const parts = sessionId.split(':');
const frontend = parts[0];
const userId = parts.slice(1).join(':');
const session = deps.sessionManager.getSession(frontend, userId);
const allMessages = session.getHistory();
const start = offset ?? 0;
const end = limit ? start + limit : allMessages.length;
const messages = allMessages.slice(start, end);
return makeResponse(request.id, {
messages,
total: allMessages.length,
});
},
'sessions.create': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
const sessionId = params?.sessionId ?? `ws:${Date.now()}`;
const parts = sessionId.split(':');
const frontend = parts[0];
const userId = parts.slice(1).join(':');
// Creating a session via getSession is idempotent
deps.sessionManager.getSession(frontend, userId);
return makeResponse(request.id, { sessionId });
},
};
}