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
+77
View File
@@ -0,0 +1,77 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import type { SendFn } from '../router.js';
import { makeEvent, makeError, ErrorCode } from '../protocol.js';
import type { SessionBridge } from '../session-bridge.js';
export interface AgentHandlerDeps {
sessionBridge: SessionBridge;
}
export function createAgentHandlers(deps: AgentHandlerDeps) {
return {
'agent.send': async (request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> => {
const params = request.params as { message?: string; connectionId?: string } | undefined;
if (!params?.message) {
return makeError(request.id, ErrorCode.InvalidRequest, 'message is required');
}
const connectionId = params.connectionId as string;
if (!connectionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required (set by server)');
}
if (deps.sessionBridge.isBusy(connectionId)) {
return makeError(request.id, ErrorCode.AgentBusy, 'Agent is already processing a request');
}
const agent = deps.sessionBridge.getAgent(connectionId);
if (!agent) {
return makeError(request.id, ErrorCode.SessionNotFound, 'No agent for this connection');
}
deps.sessionBridge.setBusy(connectionId, true);
// Set up tool use callback to emit streaming events
deps.sessionBridge.setOnToolUse(connectionId, (event) => {
if (event.type === 'start') {
send(makeEvent(request.id, 'tool_start', { tool: event.tool, args: event.args }));
} else if (event.type === 'end') {
send(makeEvent(request.id, 'tool_end', {
tool: event.tool,
result: event.result ? {
success: event.result.success,
output: event.result.output,
error: event.result.error,
} : undefined,
}));
}
});
try {
const response = await agent.process(params.message);
send(makeEvent(request.id, 'done', { content: response }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
send(makeEvent(request.id, 'error', { code: ErrorCode.InternalError, message }));
} finally {
deps.sessionBridge.setBusy(connectionId, false);
deps.sessionBridge.setOnToolUse(connectionId, undefined);
}
},
'agent.cancel': async (request: GatewayRequest): Promise<OutboundMessage> => {
// Cancel is a placeholder — proper cancellation requires abort controller support in NativeAgent.
// For now, just report whether the agent was busy.
const params = request.params as { connectionId?: string } | undefined;
const connectionId = params?.connectionId as string;
if (!connectionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
}
const wasBusy = deps.sessionBridge.isBusy(connectionId);
// TODO: Wire AbortController into NativeAgent for actual cancellation
return { id: request.id, result: { cancelled: wasBusy } };
},
};
}