diff --git a/src/cli/completion.test.ts b/src/cli/completion.test.ts new file mode 100644 index 0000000..adb1307 --- /dev/null +++ b/src/cli/completion.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { generateBashCompletion, generateZshCompletion, generateFishCompletion } from './completion.js'; + +const EXPECTED_SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion']; + +describe('generateBashCompletion', () => { + it('generates valid bash completion', () => { + const script = generateBashCompletion(); + expect(script).toContain('_flynn_completions'); + expect(script).toContain('complete -F _flynn_completions flynn'); + }); + + it('contains all subcommands', () => { + const script = generateBashCompletion(); + for (const cmd of EXPECTED_SUBCOMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('contains global options', () => { + const script = generateBashCompletion(); + expect(script).toContain('--help'); + expect(script).toContain('--version'); + }); + + it('contains subcommand-specific options', () => { + const script = generateBashCompletion(); + expect(script).toContain('--config'); + expect(script).toContain('--fullscreen'); + expect(script).toContain('--no-tools'); + expect(script).toContain('--raw'); + }); +}); + +describe('generateZshCompletion', () => { + it('generates valid zsh completion', () => { + const script = generateZshCompletion(); + expect(script).toContain('#compdef flynn'); + expect(script).toContain('compdef _flynn flynn'); + }); + + it('contains all subcommands', () => { + const script = generateZshCompletion(); + for (const cmd of EXPECTED_SUBCOMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('contains subcommand-specific options', () => { + const script = generateZshCompletion(); + expect(script).toContain('--config'); + expect(script).toContain('--fullscreen'); + }); +}); + +describe('generateFishCompletion', () => { + it('generates valid fish completion', () => { + const script = generateFishCompletion(); + expect(script).toContain('complete -c flynn'); + expect(script).toContain('__fish_use_subcommand'); + }); + + it('contains all subcommands', () => { + const script = generateFishCompletion(); + for (const cmd of EXPECTED_SUBCOMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('contains subcommand-specific options', () => { + const script = generateFishCompletion(); + expect(script).toContain('__fish_seen_subcommand_from start'); + expect(script).toContain('__fish_seen_subcommand_from tui'); + }); + + it('contains shell type completions for completion subcommand', () => { + const script = generateFishCompletion(); + expect(script).toContain("'bash zsh fish'"); + }); +}); diff --git a/src/cli/completion.ts b/src/cli/completion.ts new file mode 100644 index 0000000..7ab349c --- /dev/null +++ b/src/cli/completion.ts @@ -0,0 +1,149 @@ +import type { Command } from 'commander'; +import { mkdirSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +const SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion']; + +const SUBCOMMAND_OPTIONS: Record = { + start: ['-c', '--config'], + tui: ['-c', '--config', '-f', '--fullscreen'], + send: ['-c', '--config', '--no-tools'], + config: ['-c', '--config', '--raw'], + completion: ['--install'], +}; + +export function generateBashCompletion(): string { + const subcmds = SUBCOMMANDS.join(' '); + const cases = Object.entries(SUBCOMMAND_OPTIONS) + .map(([cmd, opts]) => ` ${cmd}) COMPREPLY=( $(compgen -W "${opts.join(' ')}" -- "$cur") ) ;;`) + .join('\n'); + + return `# Flynn bash completion — generated by 'flynn completion bash' +_flynn_completions() { + local cur prev subcmd + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + + if [[ \${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${subcmds} --help --version" -- "$cur") ) + return + fi + + subcmd="\${COMP_WORDS[1]}" + case "$subcmd" in +${cases} + completion) COMPREPLY=( $(compgen -W "bash zsh fish --install" -- "$cur") ) ;; + *) COMPREPLY=() ;; + esac +} +complete -F _flynn_completions flynn +`; +} + +export function generateZshCompletion(): string { + const subcmds = SUBCOMMANDS.map(c => `'${c}:${c} subcommand'`).join(' '); + const cases = Object.entries(SUBCOMMAND_OPTIONS) + .map(([cmd, opts]) => { + const flags = opts.filter(o => o.startsWith('--')).map(o => `'${o}'`).join(' '); + return ` ${cmd}) compadd ${flags} ;;`; + }) + .join('\n'); + + return `#compdef flynn +# Flynn zsh completion — generated by 'flynn completion zsh' +_flynn() { + local -a subcmds + subcmds=(${subcmds}) + + if (( CURRENT == 2 )); then + _describe 'command' subcmds + return + fi + + case "\${words[2]}" in +${cases} + completion) compadd bash zsh fish '--install' ;; + *) ;; + esac +} +compdef _flynn flynn +`; +} + +export function generateFishCompletion(): string { + const lines = [ + `# Flynn fish completion — generated by 'flynn completion fish'`, + `# Disable file completions by default`, + `complete -c flynn -f`, + ``, + `# Subcommands`, + ]; + + for (const cmd of SUBCOMMANDS) { + lines.push(`complete -c flynn -n '__fish_use_subcommand' -a '${cmd}' -d '${cmd} subcommand'`); + } + lines.push(`complete -c flynn -n '__fish_use_subcommand' -l help -d 'Show help'`); + lines.push(`complete -c flynn -n '__fish_use_subcommand' -l version -d 'Show version'`); + lines.push(''); + lines.push('# Subcommand options'); + + for (const [cmd, opts] of Object.entries(SUBCOMMAND_OPTIONS)) { + for (const opt of opts) { + const flag = opt.replace(/^-+/, ''); + const short = opt.startsWith('--') ? '' : ` -s ${flag}`; + const long = opt.startsWith('--') ? ` -l ${flag}` : ''; + lines.push(`complete -c flynn -n '__fish_seen_subcommand_from ${cmd}'${short}${long}`); + } + } + + lines.push(`complete -c flynn -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'Shell type'`); + lines.push(''); + + return lines.join('\n'); +} + +function getInstallPath(shell: string): string { + const home = homedir(); + switch (shell) { + case 'bash': return resolve(home, '.local/share/bash-completion/completions/flynn'); + case 'zsh': return resolve(home, '.zfunc/_flynn'); + case 'fish': return resolve(home, '.config/fish/completions/flynn.fish'); + default: throw new Error(`Unknown shell: ${shell}`); + } +} + +export function registerCompletionCommand(program: Command): void { + program + .command('completion') + .description('Generate shell completion script') + .argument('', 'Shell type: bash, zsh, or fish') + .option('--install', 'Install to standard location') + .action((shell: string, opts: { install?: boolean }) => { + const generators: Record string> = { + bash: generateBashCompletion, + zsh: generateZshCompletion, + fish: generateFishCompletion, + }; + + const generator = generators[shell]; + if (!generator) { + console.error(`Unknown shell: ${shell}. Supported: bash, zsh, fish`); + process.exit(1); + } + + const script = generator(); + + if (opts.install) { + const installPath = getInstallPath(shell); + mkdirSync(resolve(installPath, '..'), { recursive: true }); + writeFileSync(installPath, script, 'utf-8'); + console.log(`Completion script installed to: ${installPath}`); + if (shell === 'zsh') { + console.log(`Ensure ~/.zfunc is in your fpath: fpath=(~/.zfunc $fpath)`); + } + } else { + process.stdout.write(script); + } + }); +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 84172d4..c10dc9c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -185,6 +185,22 @@ const checkSkills: Check = async (ctx) => { } }; +const checkTailscale: Check = async (ctx) => { + if (!ctx.config?.server?.tailscale?.serve) { + return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' }; + } + try { + const { isTailscaleAvailable } = await import('../gateway/tailscale.js'); + const result = await isTailscaleAvailable(); + if (result.available) { + return { status: 'pass', label: 'Tailscale Serve', detail: `(v${result.version})` }; + } + return { status: 'fail', label: 'Tailscale Serve', detail: result.error ?? 'Tailscale not available' }; + } catch (err) { + return { status: 'fail', label: 'Tailscale Serve', detail: err instanceof Error ? err.message : String(err) }; + } +}; + const allChecks: Check[] = [ checkConfigExists, checkConfigParses, @@ -196,6 +212,7 @@ const allChecks: Check[] = [ checkTelegram, checkMcpServers, checkSkills, + checkTailscale, ]; /** Run all doctor checks in order. Exported for testing. */ diff --git a/src/cli/index.ts b/src/cli/index.ts index 59b9453..70b6314 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { registerSessionsCommand } from './sessions.js'; import { registerDoctorCommand } from './doctor.js'; import { registerConfigCommand } from './config-cmd.js'; import { registerTuiCommand } from './tui.js'; +import { registerCompletionCommand } from './completion.js'; export function createProgram(): Command { const program = new Command(); @@ -21,6 +22,7 @@ export function createProgram(): Command { registerSessionsCommand(program); registerDoctorCommand(program); registerConfigCommand(program); + registerCompletionCommand(program); return program; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 28f0933..ac67d95 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -6,8 +6,26 @@ const telegramSchema = z.object({ require_mention: z.boolean().default(true), }); +const tailscaleSchema = z.object({ + /** Enable Tailscale Serve to expose gateway on tailnet. */ + serve: z.boolean().default(false), + /** Custom hostname for Tailscale Serve. Defaults to machine hostname. */ + hostname: z.string().optional(), + /** Tailscale Serve HTTPS port. */ + port: z.number().default(443), +}).default({}); + +const pairingSchema = z.object({ + /** Enable DM pairing codes for unknown senders. */ + enabled: z.boolean().default(false), + /** Pairing code time-to-live duration (e.g. '5m', '1h'). */ + code_ttl: z.string().default('5m'), + /** Length of generated pairing codes. */ + code_length: z.number().default(6), +}).default({}); + const serverSchema = z.object({ - tailscale_only: z.boolean().default(true), + tailscale: tailscaleSchema, localhost: z.boolean().default(true), port: z.number().default(18800), /** Static bearer token for gateway auth. If set, all connections must provide it. */ @@ -16,6 +34,8 @@ const serverSchema = z.object({ tailscale_identity: z.boolean().default(false), /** Apply token auth to HTTP requests too (not just WebSocket). Default: true when token is set. */ auth_http: z.boolean().default(true), + /** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */ + lock: z.boolean().default(false), }); const modelConfigBaseSchema = z.object({ diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8637a17..0b7564b 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -811,6 +811,7 @@ export async function startDaemon(config: Config): Promise { tailscaleIdentity: config.server.tailscale_identity, }, authHttp: config.server.auth_http, + lock: config.server.lock, uiDir: resolve(import.meta.dirname, '../gateway/ui'), config, channelRegistry, @@ -1002,6 +1003,24 @@ export async function startDaemon(config: Config): Promise { await gateway.start(); + // ── Tailscale Serve ──────────────────────────────────────────── + if (config.server.tailscale?.serve) { + const { startTailscaleServe, stopTailscaleServe } = await import('../gateway/tailscale.js'); + const tsConfig = { + localPort: config.server.port, + servePort: config.server.tailscale.port, + hostname: config.server.tailscale.hostname, + }; + try { + await startTailscaleServe(tsConfig); + lifecycle.onShutdown(async () => { + await stopTailscaleServe(tsConfig); + }); + } catch { + console.warn('Tailscale Serve failed to start — gateway still accessible on local port'); + } + } + // ── Heartbeat Monitor ────────────────────────────────────────── const heartbeatMonitor = new HeartbeatMonitor({ config: config.automation.heartbeat, diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index eab11b2..70289e2 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -444,7 +444,7 @@ describe('config handlers', () => { function makeConfig() { return { telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] }, - server: { tailscale_only: true, localhost: true, port: 18800 }, + server: { tailscale: {}, localhost: true, port: 18800 }, models: { default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' }, fallback_chain: ['anthropic'], @@ -551,7 +551,7 @@ describe('redactConfig – comprehensive credential redaction', () => { telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true }, discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true }, slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false }, - server: { tailscale_only: true, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true }, + server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true }, models: { default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def', fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' }, @@ -704,7 +704,7 @@ describe('redactConfig – comprehensive credential redaction', () => { expect((result.slack as any).allowed_channel_ids).toEqual([]); // server expect((result.server as any).port).toBe(18800); - expect((result.server as any).tailscale_only).toBe(true); + expect((result.server as any).tailscale).toBeDefined(); // models expect((result.models as any).default.provider).toBe('anthropic'); expect((result.models as any).default.model).toBe('claude'); diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 3c67692..9bb7380 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -8,6 +8,8 @@ export { LaneQueue } from './lane-queue.js'; export { authenticateRequest } from './auth.js'; export type { AuthConfig, AuthResult } from './auth.js'; export { serveStatic } from './static.js'; +export { isTailscaleAvailable, startTailscaleServe, stopTailscaleServe } from './tailscale.js'; +export type { TailscaleServeConfig } from './tailscale.js'; export { ErrorCode, isValidRequest, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1dfb06c..22fa23f 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -11,6 +11,7 @@ import type { AuthConfig } from './auth.js'; import { parseMessage, makeError, + makeResponse, ErrorCode, type OutboundMessage, } from './protocol.js'; @@ -39,6 +40,8 @@ export interface GatewayServerConfig { toolExecutor: ToolExecutor; version?: string; auth?: AuthConfig; + /** When true, only one WebSocket client can be connected at a time. */ + lock?: boolean; /** Whether to apply token auth to HTTP requests too (default: true when token is set). */ authHttp?: boolean; uiDir?: string; @@ -159,6 +162,15 @@ export class GatewayServer { this.handleConnection(ws, authResult.identity); }); + // Register system.lock handler (needs access to connectionMap) + this.router.register('system.lock', async (request) => { + return makeResponse(request.id, { + locked: this.config.lock ?? false, + activeClients: this.connectionMap.size, + maxClients: this.config.lock ? 1 : null, + }); + }); + this.httpServer.listen(port, host, () => { console.log(`Gateway server listening on ${host}:${port}`); resolve(); @@ -199,6 +211,12 @@ export class GatewayServer { } private handleConnection(ws: WebSocket, identity?: string): void { + // Gateway lock — reject if another client is already connected + if (this.config.lock && this.connectionMap.size > 0) { + ws.close(4003, 'Gateway locked — another client is already connected'); + return; + } + const connectionId = randomUUID(); this.sessionBridge.connect(connectionId); this.connectionMap.set(ws, connectionId); diff --git a/src/gateway/tailscale.test.ts b/src/gateway/tailscale.test.ts new file mode 100644 index 0000000..2ab7347 --- /dev/null +++ b/src/gateway/tailscale.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { execFile } from 'child_process'; + +// Mock child_process before importing module +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +const mockExecFile = vi.mocked(execFile); + +describe('tailscale', () => { + // Import after mocking + let isTailscaleAvailable: typeof import('./tailscale.js').isTailscaleAvailable; + let startTailscaleServe: typeof import('./tailscale.js').startTailscaleServe; + let stopTailscaleServe: typeof import('./tailscale.js').stopTailscaleServe; + + beforeAll(async () => { + const mod = await import('./tailscale.js'); + isTailscaleAvailable = mod.isTailscaleAvailable; + startTailscaleServe = mod.startTailscaleServe; + stopTailscaleServe = mod.stopTailscaleServe; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isTailscaleAvailable', () => { + it('returns available when tailscale CLI works', async () => { + mockExecFile + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, '1.62.0', ''); + return {} as any; + }) + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, '{}', ''); + return {} as any; + }); + + const result = await isTailscaleAvailable(); + expect(result.available).toBe(true); + expect(result.version).toBe('1.62.0'); + }); + + it('returns unavailable when tailscale CLI fails', async () => { + mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(new Error('command not found'), '', 'command not found'); + return {} as any; + }); + + const result = await isTailscaleAvailable(); + expect(result.available).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('startTailscaleServe', () => { + it('calls tailscale serve with correct args', async () => { + mockExecFile + // serve command + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, '', ''); + return {} as any; + }) + // status for hostname + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), ''); + return {} as any; + }); + + const url = await startTailscaleServe({ localPort: 18800 }); + expect(url).toBe('https://myhost.tailnet.ts.net'); + + const serveCall = mockExecFile.mock.calls[0]; + expect(serveCall[0]).toBe('tailscale'); + expect(serveCall[1]).toEqual(['serve', '--bg', '--https=443', 'http://127.0.0.1:18800']); + }); + + it('uses custom serve port', async () => { + mockExecFile + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, '', ''); + return {} as any; + }) + .mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), ''); + return {} as any; + }); + + const url = await startTailscaleServe({ localPort: 18800, servePort: 8443 }); + expect(url).toBe('https://myhost.tailnet.ts.net:8443'); + + const serveCall = mockExecFile.mock.calls[0]; + expect(serveCall[1]).toEqual(['serve', '--bg', '--https=8443', 'http://127.0.0.1:18800']); + }); + }); + + describe('stopTailscaleServe', () => { + it('calls tailscale serve off', async () => { + mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(null, '', ''); + return {} as any; + }); + + await stopTailscaleServe({ localPort: 18800 }); + + expect(mockExecFile).toHaveBeenCalledOnce(); + const call = mockExecFile.mock.calls[0]; + expect(call[0]).toBe('tailscale'); + expect(call[1]).toEqual(['serve', '--https=443', 'off']); + }); + + it('does not throw on failure', async () => { + mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => { + callback(new Error('failed'), '', 'failed'); + return {} as any; + }); + + // Should not throw + await stopTailscaleServe({ localPort: 18800 }); + }); + }); +}); diff --git a/src/gateway/tailscale.ts b/src/gateway/tailscale.ts new file mode 100644 index 0000000..d1ac4d5 --- /dev/null +++ b/src/gateway/tailscale.ts @@ -0,0 +1,94 @@ +import { execFile } from 'child_process'; + +export interface TailscaleServeConfig { + /** The gateway's local port (e.g. 18800). */ + localPort: number; + /** Tailscale Serve HTTPS port (default 443). */ + servePort?: number; + /** Custom hostname (default: machine hostname). */ + hostname?: string; +} + +/** Execute a command with a timeout, returning stdout. */ +function exec(cmd: string, args: string[], timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || error.message}`)); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +/** + * Check if Tailscale CLI is available and the daemon is running. + */ +export async function isTailscaleAvailable(): Promise<{ available: boolean; version?: string; error?: string }> { + try { + const version = await exec('tailscale', ['version', '--short']); + // Check if daemon is running by getting status + await exec('tailscale', ['status', '--json']); + return { available: true, version }; + } catch (err) { + return { available: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Get the MagicDNS hostname from Tailscale status. + */ +async function getTailscaleHostname(): Promise { + try { + const statusJson = await exec('tailscale', ['status', '--json']); + const status = JSON.parse(statusJson) as { Self?: { DNSName?: string } }; + const dnsName = status.Self?.DNSName; + // DNSName ends with a trailing dot — strip it + return dnsName?.replace(/\.$/, ''); + } catch { + return undefined; + } +} + +/** + * Start Tailscale Serve to expose the local gateway port on the tailnet. + * + * Runs: `tailscale serve --bg --https= http://127.0.0.1:` + */ +export async function startTailscaleServe(config: TailscaleServeConfig): Promise { + const servePort = config.servePort ?? 443; + const target = `http://127.0.0.1:${config.localPort}`; + + try { + await exec('tailscale', ['serve', '--bg', `--https=${servePort}`, target]); + const hostname = config.hostname ?? await getTailscaleHostname(); + if (hostname) { + const url = servePort === 443 ? `https://${hostname}` : `https://${hostname}:${servePort}`; + console.log(`Tailscale Serve: Flynn available at ${url}`); + return url; + } + console.log('Tailscale Serve: started (could not determine hostname)'); + return undefined; + } catch (err) { + console.error('Failed to start Tailscale Serve:', err instanceof Error ? err.message : err); + throw err; + } +} + +/** + * Stop Tailscale Serve for the configured port. + * + * Runs: `tailscale serve --https= off` + */ +export async function stopTailscaleServe(config: TailscaleServeConfig): Promise { + const servePort = config.servePort ?? 443; + + try { + await exec('tailscale', ['serve', `--https=${servePort}`, 'off']); + console.log('Tailscale Serve: stopped'); + } catch (err) { + // Best effort — don't fail shutdown + console.error('Failed to stop Tailscale Serve:', err instanceof Error ? err.message : err); + } +} diff --git a/src/gateway/ui/lib/ws-client.js b/src/gateway/ui/lib/ws-client.js index 87945ff..a66ca38 100644 --- a/src/gateway/ui/lib/ws-client.js +++ b/src/gateway/ui/lib/ws-client.js @@ -61,8 +61,14 @@ export class FlynnClient { this._handleMessage(event.data); }; - this._ws.onclose = () => { + this._ws.onclose = (event) => { this._ws = null; + // Gateway lock — show specific message and don't auto-reconnect + if (event.code === 4003) { + this._setStatus('locked'); + this._autoReconnect = false; + return; + } this._setStatus('disconnected'); // Reject all pending requests for (const [id, pending] of this._pending) {