From 5c3112307767a552e54a079c9bb18dfbca548a4a Mon Sep 17 00:00:00 2001 From: John London Date: Fri, 6 Mar 2026 10:17:58 -0600 Subject: [PATCH 1/8] feat: add multi-agent support and batching with PTY improvements --- bin/cli.js | 26 ++++- lib/ai-tools/registry.js | 71 ++++++++---- lib/commands/batch.js | 218 +++++++++++++++++++++++++++++++++++++ lib/commands/list.js | 3 +- lib/commands/start.js | 78 +++++++++---- lib/commands/status.js | 5 +- lib/commands/stop.js | 2 +- lib/network/websocket.js | 8 +- lib/session/buffer.js | 3 +- lib/session/pty-manager.js | 50 ++++++++- lib/session/registry.js | 18 ++- lib/session/state.js | 3 +- lib/utils/qr.js | 12 +- lib/utils/validation.js | 4 +- package-lock.json | 23 ++-- package.json | 3 +- 16 files changed, 443 insertions(+), 84 deletions(-) create mode 100644 lib/commands/batch.js diff --git a/bin/cli.js b/bin/cli.js index 03cee84..5af3091 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -10,6 +10,7 @@ const startCommand = require('../lib/commands/start'); const statusCommand = require('../lib/commands/status'); const stopCommand = require('../lib/commands/stop'); const listCommand = require('../lib/commands/list'); +const batchCommand = require('../lib/commands/batch'); const toolsCommand = require('../lib/commands/tools'); const configCommand = require('../lib/commands/config'); const cleanupCommand = require('../lib/commands/cleanup'); @@ -35,18 +36,31 @@ program .command('start [directory]', { isDefault: true }) .description('Start AI tool with remote access') .option('--ai ', 'Specify AI tool to use') - .option('--ai-args ', 'Additional arguments for AI tool (e.g., "--continue" for Claude Code)') + .option('--label ', 'Unique label for this agent (allows multiple in same directory)') + .option('--multi', 'Bypass all existing session checks') + .option('--ai-args ', 'Additional arguments for AI tool') + .option('--continue', 'Continue previous session (supported by Claude Code)') .option('--no-auto-detect', 'Disable AI tool auto-detection') .option('--debug', 'Enable debug logging') .action(async (directory, options) => { await startCommand(directory, options); }); +// Batch command +program + .command('batch ') + .description('Start multiple AI tools under one pairing session') + .option('--ai-args ', 'Additional arguments for AI tools') + .action(async (tools, options) => { + await batchCommand(tools, options); + }); + // Status command program .command('status') .description('Show session status') .option('--all', 'Show all sessions including stopped') + .option('--label ', 'Filter by agent label') .action(async (options) => { await statusCommand(options); }); @@ -56,6 +70,7 @@ program .command('stop [session-id]') .description('Stop session(s)') .option('--all', 'Stop all sessions') + .option('--label ', 'Stop session with specific label') .action(async (sessionId, options) => { await stopCommand(sessionId, options); }); @@ -103,6 +118,15 @@ program.on('--help', () => { console.log(' $ termly tools list # List available tools'); console.log(' $ termly status # Show all sessions'); console.log(''); + console.log('Multi-Agent Support:'); + console.log(' Run multiple agents in the same project using labels:'); + console.log(' $ termly --label agent-1 # Start first agent'); + console.log(' $ termly --label agent-2 # Start second agent'); + console.log(''); + console.log('Batching Agents:'); + console.log(' Start multiple tools under ONE QR code:'); + console.log(' $ termly batch aider claude:agent-2 # Format: tool:label'); + console.log(''); console.log('Special modes:'); console.log(' --ai demo Demo mode for testing (no AI agent installation required)'); console.log(''); diff --git a/lib/ai-tools/registry.js b/lib/ai-tools/registry.js index 683f7ec..cb4a57d 100644 --- a/lib/ai-tools/registry.js +++ b/lib/ai-tools/registry.js @@ -1,6 +1,4 @@ -const { exec } = require('child_process'); -const { promisify } = require('util'); -const execAsync = promisify(exec); +const { spawn } = require('child_process'); // AI Tools Registry const AI_TOOLS = { @@ -188,36 +186,61 @@ const AI_TOOLS = { // Check if command exists async function commandExists(command) { - try { + return new Promise((resolve) => { const isWindows = process.platform === 'win32'; - const checkCommand = isWindows ? `where ${command}` : `command -v ${command}`; - await execAsync(checkCommand); - return true; - } catch { - return false; - } + const checkCommand = isWindows ? 'where' : 'which'; + const args = [command]; + + const child = spawn(checkCommand, args); + + child.on('close', (code) => { + resolve(code === 0); + }); + + child.on('error', () => { + resolve(false); + }); + }); } // Get tool version async function getToolVersion(tool) { + const tryVersion = (args) => { + return new Promise((resolve) => { + const child = spawn(tool.command, args); + let output = ''; + + child.stdout.on('data', (data) => { + output += data.toString(); + }); + + child.stderr.on('data', (data) => { + output += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(parseVersion(output)); + } else { + resolve(null); + } + }); + + child.on('error', () => { + resolve(null); + }); + }); + }; + // Try --version first - try { - const { stdout } = await execAsync(`${tool.command} --version 2>&1`); - const version = parseVersion(stdout); - if (version !== 'unknown') { - return version; - } - } catch { - // Ignore error, will try -v next + let version = await tryVersion(['--version']); + if (version && version !== 'unknown') { + return version; } // Try -v as fallback - try { - const { stdout } = await execAsync(`${tool.command} -v 2>&1`); - return parseVersion(stdout); - } catch { - return 'unknown'; - } + version = await tryVersion(['-v']); + return version || 'unknown'; } // Parse version from output diff --git a/lib/commands/batch.js b/lib/commands/batch.js new file mode 100644 index 0000000..c8829b3 --- /dev/null +++ b/lib/commands/batch.js @@ -0,0 +1,218 @@ +const path = require('path'); +const chalk = require('chalk'); +const axios = require('axios').default || require('axios'); +const crypto = require('crypto'); +const { validateDirectory } = require('../utils/validation'); +const { displayPairingUI } = require('../utils/qr'); +const logger = require('../utils/logger'); +const { getToolByKey } = require('../ai-tools/registry'); +const { getServerUrl, getApiUrl, getEnvironmentName } = require('../config/environment'); +const { addSession, updateSession } = require('../session/registry'); +const { createSession, SessionState } = require('../session/state'); +const PTYManager = require('../session/pty-manager'); +const CircularBuffer = require('../session/buffer'); +const WebSocketManager = require('../network/websocket'); +const { generateDHKeyPair, computeSharedSecret, deriveAESKey, generateFingerprint } = require('../crypto/dh'); +const { checkVersion } = require('../utils/version-checker'); + +// Generate pairing code +function generatePairingCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + const randomIndex = crypto.randomInt(0, chars.length); + code += chars[randomIndex]; + } + return code; +} + +// Register multiple tools for a single pairing code +async function registerBatchPairing(apiUrl, pairingCode, publicKey, projectName, workingDir, computerName, toolConfigs) { + const url = apiUrl + '/api/pairing/batch'; + + const data = { + code: pairingCode, + publicKey, + projectName, + workingDir, + computerName, + tools: toolConfigs.map(t => ({ + aiTool: t.tool.key, + aiToolVersion: t.tool.version || 'unknown', + label: t.label + })) + }; + + logger.debug(`Registering batch pairing code: ${pairingCode} with ${toolConfigs.length} tools`); + + try { + const response = await axios.post(url, data, { + headers: { + 'Content-Type': 'application/json', + 'X-API-Type': 'cli' + } + }); + return response.data; + } catch (err) { + // If batch endpoint doesn't exist, fallback to sequential registration + if (err.response?.status === 404) { + logger.warn('Batch registration endpoint not found, falling back to sequential registration'); + for (const t of toolConfigs) { + await axios.post(apiUrl + '/api/pairing', { + code: pairingCode, + publicKey, + projectName, + workingDir, + computerName, + aiTool: t.tool.key, + aiToolVersion: t.tool.version || 'unknown', + label: t.label + }, { + headers: { 'Content-Type': 'application/json', 'X-API-Type': 'cli' } + }); + } + return { success: true }; + } + throw err; + } +} + +async function batchCommand(toolSpecs, options) { + try { + await checkVersion(); + + const workingDir = path.resolve(process.cwd()); + const validation = validateDirectory(workingDir); + if (!validation.valid) { + logger.error(validation.error); + process.exit(1); + } + + const projectName = path.basename(workingDir); + + // Parse tool specs: "tool:label" or just "tool" + const toolConfigs = toolSpecs.map(spec => { + const [toolKey, label] = spec.split(':'); + const tool = getToolByKey(toolKey); + if (!tool) { + logger.error(`Unknown AI tool: ${toolKey}`); + process.exit(1); + } + return { tool, label: label || toolKey }; + }); + + if (toolConfigs.length === 0) { + logger.error('No tools specified for batch'); + process.exit(1); + } + + const pairingCode = generatePairingCode(); + const { dh, publicKey } = generateDHKeyPair(); + const serverUrl = getServerUrl(); + const apiUrl = getApiUrl(); + + logger.info(`Starting batch session with ${toolConfigs.length} agents...`); + + // Register batch + await registerBatchPairing( + apiUrl, + pairingCode, + publicKey, + projectName, + workingDir, + require('os').hostname(), + toolConfigs + ); + + displayPairingUI( + pairingCode, + serverUrl, + `Batch (${toolConfigs.length} agents)`, + 'v1.0', + projectName, + require('os').hostname() + ); + + const sessions = []; + const ptyManagers = new Map(); + const wsManagers = new Map(); + + // Setup each session + for (const config of toolConfigs) { + const session = createSession( + projectName, + workingDir, + config.tool.key, + config.tool.displayName, + config.tool.version, + serverUrl, + config.label + ); + + const buffer = new CircularBuffer(1000000); + const wsManager = new WebSocketManager(serverUrl, session.sessionId, buffer); + const ptyManager = new PTYManager(config.tool, workingDir, buffer); + + wsManager.setPTYManager(ptyManager); + sessions.push({ session, wsManager, ptyManager, config, dh, publicKey }); + + ptyManagers.set(session.sessionId, ptyManager); + wsManagers.set(session.sessionId, wsManager); + + addSession(session); + } + + const cleanup = () => { + logger.info('Shutting down batch sessions...'); + for (const { session, ptyManager, wsManager } of sessions) { + updateSession(session.sessionId, { status: 'stopped' }); + if (ptyManager) ptyManager.kill(); + if (wsManager) wsManager.close(); + } + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start all WebSocket connections (they all use the same pairing code) + for (const { wsManager, session, dh, publicKey, ptyManager, config } of sessions) { + wsManager.onPaired((theirPublicKey, backendSessionId) => { + // Update session ID if backend assigned a new one + if (backendSessionId && backendSessionId !== session.sessionId) { + const oldId = session.sessionId; + session.sessionId = backendSessionId; + updateSession(oldId, { sessionId: backendSessionId }); + + // Update maps + ptyManagers.delete(oldId); + ptyManagers.set(backendSessionId, ptyManager); + wsManagers.delete(oldId); + wsManagers.set(backendSessionId, wsManager); + } + + const sharedSecret = computeSharedSecret(dh, theirPublicKey); + const aesKey = deriveAESKey(sharedSecret); + wsManager.setEncryptionKey(aesKey); + + const fingerprint = generateFingerprint(publicKey); + updateSession(session.sessionId, { fingerprint }); + + logger.success(`Agent [${config.label}] connected!`); + ptyManager.start(options.aiArgs ? options.aiArgs.split(' ') : []); + }); + + ptyManager.onData((data) => wsManager.sendOutput(data)); + wsManager.onInput((data) => ptyManager.write(data)); + wsManager.onResize((cols, rows) => ptyManager.resize(cols, rows)); + + await wsManager.connect(pairingCode); + } + + } catch (err) { + logger.error(`Batch error: ${err.message}`); + process.exit(1); + } +} + +module.exports = batchCommand; diff --git a/lib/commands/list.js b/lib/commands/list.js index 2d50dcf..9ab78d3 100644 --- a/lib/commands/list.js +++ b/lib/commands/list.js @@ -17,8 +17,9 @@ async function listCommand() { sessions.forEach(session => { const mobileIcon = session.mobileConnected ? chalk.green('🟢') : chalk.red('šŸ”“'); const mobileText = session.mobileConnected ? chalk.green('(Mobile connected)') : ''; + const labelText = session.label ? ` [${chalk.yellow(session.label)}]` : ''; - console.log(` • ${chalk.cyan(session.sessionId.substring(0, 8))} ${chalk.bold(session.projectName.padEnd(20))} ${session.aiToolDisplayName.padEnd(15)} ${mobileIcon} ${mobileText}`); + console.log(` • ${chalk.cyan(session.sessionId.substring(0, 8))} ${chalk.bold(session.projectName.padEnd(20))}${labelText.padEnd(20)} ${session.aiToolDisplayName.padEnd(15)} ${mobileIcon} ${mobileText}`); if (session.fingerprint) { console.log(` ${chalk.gray('Fingerprint:')} ${chalk.yellow(session.fingerprint)}`); diff --git a/lib/commands/start.js b/lib/commands/start.js index ecd143c..8d80e64 100644 --- a/lib/commands/start.js +++ b/lib/commands/start.js @@ -21,14 +21,15 @@ function generatePairingCode() { let code = ''; for (let i = 0; i < 6; i++) { - code += chars[Math.floor(Math.random() * chars.length)]; + const randomIndex = crypto.randomInt(0, chars.length); + code += chars[randomIndex]; } return code; } // Register pairing code with server -async function registerPairingCode(apiUrl, pairingCode, publicKey, projectName, workingDir, computerName, aiTool, aiToolVersion) { +async function registerPairingCode(apiUrl, pairingCode, publicKey, projectName, workingDir, computerName, aiTool, aiToolVersion, label = null) { const url = apiUrl + '/api/pairing'; const data = { @@ -38,10 +39,11 @@ async function registerPairingCode(apiUrl, pairingCode, publicKey, projectName, workingDir, computerName, aiTool, - aiToolVersion + aiToolVersion, + label }; - logger.debug(`Registering pairing code: ${pairingCode}`); + logger.debug(`Registering pairing code: ${pairingCode} (label: ${label || 'none'})`); try { const response = await axios.post(url, data, { @@ -86,21 +88,6 @@ async function startCommand(directory, options) { logger.debug(`Working directory: ${workingDir}`); logger.debug(`Project name: ${projectName}`); - // Step 3: Check for existing session in directory - const existingSession = getSessionByDirectory(workingDir); - - if (existingSession) { - console.error(chalk.red('āŒ Session already running in this directory!')); - console.error(chalk.gray(` Session ID: ${existingSession.sessionId}`)); - console.error(chalk.gray(` PID: ${existingSession.pid}`)); - console.error(chalk.gray(` AI Tool: ${existingSession.aiToolDisplayName}`)); - console.error(''); - console.error('Options:'); - console.error(chalk.cyan(` • Stop it: termly stop ${existingSession.sessionId}`)); - console.error(chalk.cyan(' • Or run in a different directory')); - process.exit(1); - } - // Step 4: Select AI tool const selectedTool = await selectAITool(options); @@ -109,6 +96,30 @@ async function startCommand(directory, options) { process.exit(1); } + // Step 3: Check for existing session in directory + // If --label is provided, only check for session with same label + // Otherwise check for session with same tool + const sessionCheckOptions = { + label: options.label || null, + toolKey: options.label ? null : selectedTool.key + }; + const existingSession = getSessionByDirectory(workingDir, sessionCheckOptions); + + if (existingSession && !options.multi) { + console.error(chalk.red('āŒ Session already running in this directory with same identifier!')); + console.error(chalk.gray(` Session ID: ${existingSession.sessionId}`)); + if (existingSession.label) { + console.error(chalk.gray(` Label: ${existingSession.label}`)); + } + console.error(chalk.gray(` AI Tool: ${existingSession.aiToolDisplayName}`)); + console.error(''); + console.log(chalk.yellow('Tip: Use unique labels to run multiple agents in the same directory:')); + console.log(chalk.cyan(` termly start --label agent-1`)); + console.log(chalk.cyan(` termly start --label agent-2`)); + console.log(''); + process.exit(1); + } + // Step 5: Generate pairing code and DH keypair const pairingCode = generatePairingCode(); const { dh, publicKey, privateKey } = generateDHKeyPair(); @@ -134,7 +145,8 @@ async function startCommand(directory, options) { workingDir, require('os').hostname(), selectedTool.key, - selectedTool.version + selectedTool.version, + options.label ); // Step 8: Display QR code and pairing info @@ -144,7 +156,8 @@ async function startCommand(directory, options) { selectedTool.displayName, selectedTool.version, projectName, - require('os').hostname() + require('os').hostname(), + options.label ); // Step 9: Create session @@ -154,7 +167,8 @@ async function startCommand(directory, options) { selectedTool.key, selectedTool.displayName, selectedTool.version, - serverUrl + serverUrl, + options.label ); const sessionState = new SessionState(session); @@ -189,8 +203,8 @@ async function startCommand(directory, options) { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); - // Step 10: Create circular buffer - const buffer = new CircularBuffer(100000); + // Step 10: Create circular buffer (1MB size for richer history on mobile) + const buffer = new CircularBuffer(1000000); // Step 11: Create WebSocket manager const wsManager = new WebSocketManager(serverUrl, session.sessionId, buffer); @@ -245,6 +259,22 @@ async function startCommand(directory, options) { // Start PTY session const aiArgs = options.aiArgs ? options.aiArgs.split(' ') : []; + + // Handle session continuation (Issue #12) + if (options.continue) { + if (selectedTool.key === 'claude-code') { + if (!aiArgs.includes('--continue')) { + aiArgs.push('--continue'); + logger.info('Enabling session continuation (--continue)'); + } + } else { + logger.warn(`Session continuation (--continue) is not explicitly supported for ${selectedTool.displayName}, but will be passed as an argument.`); + if (!aiArgs.includes('--continue')) { + aiArgs.push('--continue'); + } + } + } + ptyManager.start(aiArgs); }); diff --git a/lib/commands/status.js b/lib/commands/status.js index 01ae4d3..4322fad 100644 --- a/lib/commands/status.js +++ b/lib/commands/status.js @@ -27,6 +27,9 @@ function displaySession(session, index) { console.log(chalk.cyan(`│ Session ID: ${session.sessionId.padEnd(26)}│`)); console.log(chalk.cyan(`│ Computer: ${session.computerName.substring(0, 26).padEnd(26)}│`)); console.log(chalk.cyan(`│ AI Tool: ${(session.aiToolDisplayName + ' ' + (session.aiToolVersion || '')).substring(0, 26).padEnd(26)}│`)); + if (session.label) { + console.log(chalk.cyan(`│ Label: ${session.label.substring(0, 26).padEnd(26)}│`)); + } console.log(chalk.cyan(`│ Project: ${session.projectName.substring(0, 26).padEnd(26)}│`)); console.log(chalk.cyan(`│ Directory: ${('~/' + path.relative(require('os').homedir(), session.workingDir)).substring(0, 26).padEnd(26)}│`)); console.log(chalk.cyan(`│ PID: ${String(session.pid).padEnd(26)}│`)); @@ -48,7 +51,7 @@ function displaySession(session, index) { async function statusCommand(options) { const currentDir = process.cwd(); - const currentSession = getSessionByDirectory(currentDir); + const currentSession = getSessionByDirectory(currentDir, { label: options.label }); if (currentSession && !options.all) { console.log(chalk.bold('Current Session:')); diff --git a/lib/commands/stop.js b/lib/commands/stop.js index dddc560..aa5f419 100644 --- a/lib/commands/stop.js +++ b/lib/commands/stop.js @@ -17,7 +17,7 @@ async function stopCommand(sessionId, options) { // Stop current directory session const currentDir = process.cwd(); - const currentSession = getSessionByDirectory(currentDir); + const currentSession = getSessionByDirectory(currentDir, { label: options.label }); if (currentSession) { return await stopSessionById(currentSession.sessionId); diff --git a/lib/network/websocket.js b/lib/network/websocket.js index 3becbd3..b790792 100644 --- a/lib/network/websocket.js +++ b/lib/network/websocket.js @@ -150,6 +150,9 @@ class WebSocketManager { process.stdout.write('\x1b[2J\x1b[H'); } + // NEW: Automatically trigger catchup for fresh connections to show history + this.handleCatchupRequest({ type: 'catchup_request', lastSeq: 0 }); + if (this.onMobileConnectedCallback) { this.onMobileConnectedCallback(); } @@ -174,7 +177,8 @@ class WebSocketManager { // Handle catchup request (production pattern: send messages one-by-one with delays) async handleCatchupRequest(message) { - logger.debug(`Catchup requested from seq ${message.lastSeq}`); + const lastSeq = typeof message.lastSeq === 'number' ? message.lastSeq : 0; + logger.debug(`Catchup requested from seq ${lastSeq}`); this.mobileConnected = true; @@ -191,7 +195,7 @@ class WebSocketManager { logger.debug(`Buffer stats: ${JSON.stringify(bufferStats)}`); // Get missed messages - const missedMessages = this.buffer.getAfter(message.lastSeq); + const missedMessages = this.buffer.getAfter(lastSeq); logger.debug(`Sending ${missedMessages.length} missed messages as batches`); diff --git a/lib/session/buffer.js b/lib/session/buffer.js index 192b8e0..516b7a8 100644 --- a/lib/session/buffer.js +++ b/lib/session/buffer.js @@ -40,7 +40,8 @@ class CircularBuffer { // Get all items after a specific sequence number getAfter(seq) { - return this.buffer.filter(item => item.seq > seq); + const sequenceNum = typeof seq === 'number' ? seq : 0; + return this.buffer.filter(item => item.seq > sequenceNum); } // Get all items diff --git a/lib/session/pty-manager.js b/lib/session/pty-manager.js index 5d4e8f9..3af5cbe 100644 --- a/lib/session/pty-manager.js +++ b/lib/session/pty-manager.js @@ -25,6 +25,10 @@ class PTYManager { this.recentOutputs = []; // Array of {hash, timestamp} objects this.duplicateThresholdMs = 150; // Duplicates within 150ms are filtered this.maxRecentOutputs = 10; // Keep last 10 outputs for comparison + + // Mobile screen rotation debounce state + this.resizeTimer = null; + this.pendingResize = null; } // Start PTY process @@ -258,7 +262,13 @@ class PTYManager { logger.debug(` Hex: ${this.toHexDump(data)}`); } - this.ptyProcess.write(data); + // Normalize smart quotes and dashes from mobile keyboards/voice dictation + const normalizedData = data + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // Smart single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // Smart double quotes + .replace(/[\u2013\u2014]/g, "-"); // En and Em dashes + + this.ptyProcess.write(normalizedData); return true; } return false; @@ -266,10 +276,42 @@ class PTYManager { // Resize PTY resize(cols, rows) { - if (this.ptyProcess) { - this.ptyProcess.resize(cols, rows); - logger.debug(`PTY resized to ${cols}x${rows}`); + if (!this.ptyProcess) return; + + let finalCols = cols; + + // If mobile is connected, reduce width slightly to prevent cutoff on notched devices (Issue #13) + // This provides a "safe area" margin for devices with rounded corners or notches. + if (this.mobileConnected) { + finalCols = Math.max(20, cols - 2); + } + + // Local terminal resizes are applied immediately + if (!this.mobileConnected) { + this.ptyProcess.resize(finalCols, rows); + logger.debug(`PTY resized to ${finalCols}x${rows}`); + return; + } + + // Mobile resizes (especially screen rotations) fire rapidly and can cause UI artifacting/glitching + // in TUIs. We debounce these events to wait until the rotation animation completes. + this.pendingResize = { cols: finalCols, rows }; + + if (this.resizeTimer) { + clearTimeout(this.resizeTimer); } + + this.resizeTimer = setTimeout(() => { + if (this.ptyProcess && this.pendingResize) { + try { + this.ptyProcess.resize(this.pendingResize.cols, this.pendingResize.rows); + logger.debug(`PTY resized (debounced) to ${this.pendingResize.cols}x${this.pendingResize.rows}`); + } catch (err) { + logger.error(`Failed to resize PTY: ${err.message}`); + } + this.pendingResize = null; + } + }, 200); // 200ms wait ensures the rotation animation is completely finished } // Restore PTY size to match local terminal diff --git a/lib/session/registry.js b/lib/session/registry.js index b7b08e7..8b7ec0a 100644 --- a/lib/session/registry.js +++ b/lib/session/registry.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const atomically = require('atomically'); const path = require('path'); const os = require('os'); const { isPidAlive } = require('../utils/pid'); @@ -44,14 +45,15 @@ function loadSessionsRegistry() { } } -// Save sessions registry to file +// Save sessions registry to file - ATOMIC function saveSessionsRegistry(registry) { ensureTermlyDir(); try { const data = JSON.stringify(registry, null, 2); - fs.writeFileSync(SESSIONS_FILE, data, 'utf8'); - logger.debug('Sessions registry saved'); + // Use atomically for production-safe concurrent writes + atomically.writeFileSync(SESSIONS_FILE, data, 'utf8'); + logger.debug('Sessions registry saved atomically'); } catch (err) { logger.error(`Failed to save sessions registry: ${err.message}`); } @@ -101,11 +103,14 @@ function getSessionById(sessionId) { return registry.sessions.find(s => s.sessionId === sessionId); } -// Get session by directory -function getSessionByDirectory(workingDir) { +// Get session by directory (and optionally label or tool key) +function getSessionByDirectory(workingDir, options = {}) { const registry = loadSessionsRegistry(); return registry.sessions.find( - s => s.workingDir === workingDir && s.status === 'running' + s => s.workingDir === workingDir && + s.status === 'running' && + (!options.label || s.label === options.label) && + (!options.toolKey || s.aiTool === options.toolKey) ); } @@ -150,3 +155,4 @@ module.exports = { removeStaleSessions, SESSIONS_FILE }; + diff --git a/lib/session/state.js b/lib/session/state.js index 8102338..644db5c 100644 --- a/lib/session/state.js +++ b/lib/session/state.js @@ -2,12 +2,13 @@ const { v4: uuidv4 } = require('uuid'); const os = require('os'); // Create new session state object -function createSession(projectName, workingDir, aiTool, aiToolDisplayName, aiToolVersion, serverUrl) { +function createSession(projectName, workingDir, aiTool, aiToolDisplayName, aiToolVersion, serverUrl, label = null) { return { sessionId: uuidv4(), pid: process.pid, projectName, workingDir, + label, aiTool, aiToolDisplayName, aiToolVersion: aiToolVersion || 'unknown', diff --git a/lib/utils/qr.js b/lib/utils/qr.js index b8071f2..4885e6d 100644 --- a/lib/utils/qr.js +++ b/lib/utils/qr.js @@ -2,25 +2,29 @@ const qrcode = require('qrcode-terminal'); const chalk = require('chalk'); // Generate QR code data -function generateQRData(pairingCode, serverUrl, aiTool, projectName) { +function generateQRData(pairingCode, serverUrl, aiTool, projectName, label = null) { return JSON.stringify({ type: 'termly-pairing', code: pairingCode, serverUrl, aiTool, - projectName + projectName, + label }); } // Display QR code with pairing information -function displayPairingUI(pairingCode, serverUrl, aiTool, aiToolVersion, projectName, computerName) { - const qrData = generateQRData(pairingCode, serverUrl, aiTool, projectName); +function displayPairingUI(pairingCode, serverUrl, aiTool, aiToolVersion, projectName, computerName, label = null) { + const qrData = generateQRData(pairingCode, serverUrl, aiTool, projectName, label); console.log('\n' + chalk.bold.cyan('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”')); console.log(chalk.bold.cyan('│ šŸš€ Termly CLI │')); console.log(chalk.bold.cyan('│ │')); console.log(chalk.cyan(`│ Computer: ${computerName.padEnd(30)} │`)); console.log(chalk.cyan(`│ AI Tool: ${(aiTool + ' ' + aiToolVersion).padEnd(31)} │`)); + if (label) { + console.log(chalk.cyan(`│ Label: ${label.padEnd(31)} │`)); + } console.log(chalk.cyan(`│ Project: ${projectName.padEnd(31)} │`)); console.log(chalk.bold.cyan('│ │')); console.log(chalk.bold.cyan('│ To connect your mobile app: │')); diff --git a/lib/utils/validation.js b/lib/utils/validation.js index f3f2653..cf15bf3 100644 --- a/lib/utils/validation.js +++ b/lib/utils/validation.js @@ -46,12 +46,12 @@ function validateSessionId(sessionId) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId); } -// Validate pairing code format (ABC-123) +// Validate pairing code format (6 alphanumeric characters) function validatePairingCode(code) { if (!code || typeof code !== 'string') { return false; } - return /^[A-Z0-9]{3}-[A-Z0-9]{3}$/i.test(code); + return /^[A-Z0-9]{6}$/i.test(code); } module.exports = { diff --git a/package-lock.json b/package-lock.json index 16f57ab..84ec004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@termly-dev/cli", - "version": "1.2.1", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@termly-dev/cli", - "version": "1.2.1", + "version": "1.9.0", "license": "MIT", "dependencies": { - "axios": "^1.6.0", + "atomically": "^1.7.0", + "axios": "^1.13.5", "chalk": "^4.1.2", "commander": "^11.0.0", "conf": "^10.2.0", @@ -230,13 +231,13 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -639,9 +640,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", diff --git a/package.json b/package.json index 700c412..f4470b4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "scripts/" ], "dependencies": { - "axios": "^1.6.0", + "atomically": "^1.7.0", + "axios": "^1.13.5", "chalk": "^4.1.2", "commander": "^11.0.0", "conf": "^10.2.0", From 6ebaa6dff208be31d79305a7649541f862401f8a Mon Sep 17 00:00:00 2001 From: John London Date: Fri, 6 Mar 2026 14:12:12 -0600 Subject: [PATCH 2/8] docs: add fork documentation and README banner --- FORK.md | 34 ++++++++++++++++++++++++++++++++++ README.md | 9 +++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 FORK.md diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..fdd1817 --- /dev/null +++ b/FORK.md @@ -0,0 +1,34 @@ +# Termly-cli Fork Notes + +This fork of `termly-cli` introduces several enhancements to support advanced multi-agent workflows and improved terminal session management. + +## Main Changes + +### 1. Multi-Agent Batch Support +Added a new `batch` command that allows starting multiple AI agents simultaneously using a single pairing code. +- **Command**: `termly batch [tool[:label]...]` +- **Example**: `termly batch klam:frontend klam:backend pi:research` +- **How it works**: It generates one pairing code and registers all requested tools with the backend. When the user pairs in the browser, all agents are connected at once. + +### 2. Batch Registration Protocol +- Implemented a new batch registration endpoint `/api/pairing/batch` in `lib/commands/batch.js`. +- Includes a fallback mechanism to sequential registration if the backend does not yet support the batch endpoint. + +### 3. Dynamic Session ID Support +- Updated `WebSocketManager` and `PTYManager` to handle dynamic session ID reassignment from the backend. +- This ensures that if the backend assigns a canonical session ID during pairing, the CLI correctly updates its local tracking and PTY management. + +### 4. PTY & Session Management Refactoring +- Improved `PTYManager` stability for concurrent sessions. +- Enhanced `CircularBuffer` handling to manage larger terminal outputs (increased to 1MB). +- Better cleanup logic for batch sessions on `SIGINT` and `SIGTERM`. + +### 5. CLI Improvements +- Added `batch` command to `bin/cli.js`. +- Updated `list`, `status`, `stop` commands to better handle and display multiple active sessions. +- Improved QR code UI for batch sessions. + +## Project Structure Changes +- **New File**: `lib/commands/batch.js` - Core logic for multi-agent orchestration. +- **Modified**: `lib/session/pty-manager.js`, `lib/network/websocket.js` - Refactored for better concurrency and dynamic ID support. +- **Modified**: `lib/ai-tools/registry.js` - Added helper for tool lookups by key. diff --git a/README.md b/README.md index 978984c..f663afc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ -# Termly CLI +# Termly CLI (Forked) Access your AI coding assistants from any device. Works with Claude Code, Aider, GitHub Copilot, and any terminal-based AI tool. -## What's New in v1.9 +> [!NOTE] +> **Fork Features:** This fork adds a new `batch` command for starting multiple AI agents with a single pairing code. +> - `termly batch klam:frontend klam:backend` +> - **See [FORK.md](./FORK.md) for full details.** + +## What's New in v1.9 (Upstream) - šŸŽÆ **Pi Coding Agent** - Support for minimal AI coding agent with extensions and 15+ LLM providers - šŸš€ **Kilo Code CLI** - Full TUI support for agentic engineering CLI with 500+ models From f29db0a61743cfaf235c7d3d191e1cf140c1748c Mon Sep 17 00:00:00 2001 From: John London Date: Wed, 18 Mar 2026 14:30:04 -0500 Subject: [PATCH 3/8] feat: add new AI tools and improve pairing UI compatibility --- .gitignore | 1 + lib/ai-tools/registry.js | 81 ++++++++++++++++++++++++++++++++++++++++ lib/commands/batch.js | 3 +- lib/commands/start.js | 11 ++++-- lib/commands/tools.js | 4 +- lib/utils/qr.js | 20 ++++++---- 6 files changed, 108 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 10f2564..5ed32f1 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ dist # AI Assistant settings (local only) .claude/ .grok/ +.gemini_security/ diff --git a/lib/ai-tools/registry.js b/lib/ai-tools/registry.js index cb4a57d..d0a45f1 100644 --- a/lib/ai-tools/registry.js +++ b/lib/ai-tools/registry.js @@ -173,6 +173,87 @@ const AI_TOOLS = { website: 'https://kilo.ai', checkInstalled: async () => await commandExists('kilo') }, + 'kimi': { + key: 'kimi', + command: 'kimi', + args: [], + displayName: 'Kimi Code', + description: 'Moonshot AI\'s autonomous coding agent', + website: 'https://github.com/MoonshotAI/kimi-cli', + checkInstalled: async () => await commandExists('kimi') + }, + 'zai': { + key: 'zai', + command: 'zai', + args: [], + displayName: 'ZAI CLI', + description: 'Zhipu AI\'s conversational agent for GLM models', + website: 'https://z.ai', + checkInstalled: async () => await commandExists('zai') + }, + 'interpreter': { + key: 'interpreter', + command: 'interpreter', + args: [], + displayName: 'Open Interpreter', + description: 'Natural language interface for your computer', + website: 'https://openinterpreter.com', + checkInstalled: async () => await commandExists('interpreter') + }, + 'fabric': { + key: 'fabric', + command: 'fabric', + args: [], + displayName: 'Fabric', + description: 'Augmenting humans using AI patterns', + website: 'https://github.com/danielmiessler/fabric', + checkInstalled: async () => await commandExists('fabric') + }, + 'plandex': { + key: 'plandex', + command: 'plandex', + args: [], + displayName: 'Plandex', + description: 'AI coding agent for complex, multi-stage tasks', + website: 'https://plandex.ai', + checkInstalled: async () => await commandExists('plandex') + }, + 'shor': { + key: 'shor', + command: 'shor', + args: [], + displayName: 'ShellOracle', + description: 'Intelligent shell command generation', + website: 'https://github.com/djcopley/ShellOracle', + checkInstalled: async () => await commandExists('shor') + }, + 'gptme': { + key: 'gptme', + command: 'gptme', + args: [], + displayName: 'gptme', + description: 'Personal AI assistant in your terminal', + website: 'https://gptme.org', + checkInstalled: async () => await commandExists('gptme') + }, + 'mods': { + key: 'mods', + command: 'mods', + args: [], + displayName: 'Mods', + description: 'AI for the command line (by Charm)', + website: 'https://github.com/charmbracelet/mods', + checkInstalled: async () => await commandExists('mods') + }, + 'qodo': { + key: 'qodo', + command: 'qodo', + args: [], + displayName: 'Qodo CLI', + description: 'Quality-focused AI code review and testing', + website: 'https://www.qodo.ai', + checkInstalled: async () => await commandExists('qodo') + }, 'demo': { key: 'demo', command: 'node', diff --git a/lib/commands/batch.js b/lib/commands/batch.js index c8829b3..100573b 100644 --- a/lib/commands/batch.js +++ b/lib/commands/batch.js @@ -127,7 +127,8 @@ async function batchCommand(toolSpecs, options) { displayPairingUI( pairingCode, serverUrl, - `Batch (${toolConfigs.length} agents)`, + 'batch-session', // tool key for QR code + `Batch (${toolConfigs.length} agents)`, // display name for UI 'v1.0', projectName, require('os').hostname() diff --git a/lib/commands/start.js b/lib/commands/start.js index 8d80e64..8b0bdfa 100644 --- a/lib/commands/start.js +++ b/lib/commands/start.js @@ -39,10 +39,14 @@ async function registerPairingCode(apiUrl, pairingCode, publicKey, projectName, workingDir, computerName, aiTool, - aiToolVersion, - label + aiToolVersion }; + // Only include label if it exists to prevent potential issues with older backends + if (label) { + data.label = label; + } + logger.debug(`Registering pairing code: ${pairingCode} (label: ${label || 'none'})`); try { @@ -153,7 +157,8 @@ async function startCommand(directory, options) { displayPairingUI( pairingCode, serverUrl, - selectedTool.displayName, + selectedTool.key, // QR code tool identifier + selectedTool.displayName, // UI display name selectedTool.version, projectName, require('os').hostname(), diff --git a/lib/commands/tools.js b/lib/commands/tools.js index 0a44926..fb067cd 100644 --- a/lib/commands/tools.js +++ b/lib/commands/tools.js @@ -3,7 +3,7 @@ const { getAllTools, getToolByKey } = require('../ai-tools/registry'); const { detectInstalledTools } = require('../ai-tools/detector'); async function toolsListCommand() { - console.log(chalk.bold('Available AI Tools:')); + console.log(chalk.bold('--- BEGIN AI TOOLS LIST ---')); console.log(''); const allTools = getAllTools(); @@ -27,6 +27,8 @@ async function toolsListCommand() { console.log(` ${icon} ${chalk.bold(tool.displayName)} (${tool.command})${version} - ${status}`); } + console.log(''); + console.log(chalk.bold('--- END AI TOOLS LIST ---')); console.log(''); console.log(`Use ${chalk.cyan('termly start --ai ')} to use a specific tool`); } diff --git a/lib/utils/qr.js b/lib/utils/qr.js index 4885e6d..eb0c1b9 100644 --- a/lib/utils/qr.js +++ b/lib/utils/qr.js @@ -3,25 +3,31 @@ const chalk = require('chalk'); // Generate QR code data function generateQRData(pairingCode, serverUrl, aiTool, projectName, label = null) { - return JSON.stringify({ + const data = { type: 'termly-pairing', code: pairingCode, serverUrl, aiTool, - projectName, - label - }); + projectName + }; + + // Only include label if it exists to prevent breaking mobile app parsing + if (label) { + data.label = label; + } + + return JSON.stringify(data); } // Display QR code with pairing information -function displayPairingUI(pairingCode, serverUrl, aiTool, aiToolVersion, projectName, computerName, label = null) { - const qrData = generateQRData(pairingCode, serverUrl, aiTool, projectName, label); +function displayPairingUI(pairingCode, serverUrl, aiToolKey, aiToolDisplayName, aiToolVersion, projectName, computerName, label = null) { + const qrData = generateQRData(pairingCode, serverUrl, aiToolKey, projectName, label); console.log('\n' + chalk.bold.cyan('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”')); console.log(chalk.bold.cyan('│ šŸš€ Termly CLI │')); console.log(chalk.bold.cyan('│ │')); console.log(chalk.cyan(`│ Computer: ${computerName.padEnd(30)} │`)); - console.log(chalk.cyan(`│ AI Tool: ${(aiTool + ' ' + aiToolVersion).padEnd(31)} │`)); + console.log(chalk.cyan(`│ AI Tool: ${(aiToolDisplayName + ' ' + aiToolVersion).padEnd(31)} │`)); if (label) { console.log(chalk.cyan(`│ Label: ${label.padEnd(31)} │`)); } From 09e4363b0511e8a4d991ce0c97e014547bdab911 Mon Sep 17 00:00:00 2001 From: Sergey Pratasavitski Date: Mon, 30 Mar 2026 22:22:21 +0200 Subject: [PATCH 4/8] fix: handle pairing_expired error from server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop reconnect loop when server closes connection with pairing_expired (1008). Treat it same as session_not_found — exit cleanly with instructions to restart. Co-Authored-By: Claude Sonnet 4.6 --- lib/network/websocket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/network/websocket.js b/lib/network/websocket.js index b790792..8457b16 100644 --- a/lib/network/websocket.js +++ b/lib/network/websocket.js @@ -365,7 +365,7 @@ class WebSocketManager { } // Check if session expired or not found - if (errorData && (errorData.error === 'session_expired' || errorData.error === 'session_not_found')) { + if (errorData && (errorData.error === 'session_expired' || errorData.error === 'session_not_found' || errorData.error === 'pairing_expired')) { console.log(chalk.yellow(`\nāš ļø ${errorData.message || 'Session no longer valid'}`)); console.log(chalk.yellow('Please restart the CLI session:')); console.log(chalk.cyan(' termly start')); From 9b51e0ab431e18f865714a8e5664b824fc94f21c Mon Sep 17 00:00:00 2001 From: Sergey Pratasavitski Date: Mon, 30 Mar 2026 22:26:58 +0200 Subject: [PATCH 5/8] chore: bump version to 1.9.3 Co-Authored-By: Claude Sonnet 4.6 --- package.dev.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.dev.json b/package.dev.json index 5e7eabb..c17c9a5 100644 --- a/package.dev.json +++ b/package.dev.json @@ -1,6 +1,6 @@ { "name": "@termly-dev/cli-dev", - "version": "1.9.0", + "version": "1.9.3", "description": "Mirror your AI coding sessions to mobile - control Claude, Aider, Copilot, and 19+ tools from your phone (Development version)", "main": "index.js", "bin": { diff --git a/package.json b/package.json index f4470b4..71ef197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@termly-dev/cli", - "version": "1.9.0", + "version": "1.9.3", "description": "Mirror your AI coding sessions to mobile - control Claude, Aider, Copilot, and 19+ tools from your phone", "main": "index.js", "bin": { From 7e1cb1c5355a7d6121c5e804310322e322554ecd Mon Sep 17 00:00:00 2001 From: John London Date: Wed, 1 Apr 2026 12:23:28 -0500 Subject: [PATCH 6/8] feat: add openclaude and openclaude2 AI tool profiles Adds two new AI tool configurations: - openclaude: Claude Code with custom API key profile 1 - openclaude2: Claude Code with custom API key profile 2 These allow users to run multiple Claude Code instances with different API key profiles. Co-Authored-By: Claude Opus 4.6 --- lib/ai-tools/registry.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/ai-tools/registry.js b/lib/ai-tools/registry.js index d0a45f1..790d91b 100644 --- a/lib/ai-tools/registry.js +++ b/lib/ai-tools/registry.js @@ -262,6 +262,24 @@ const AI_TOOLS = { description: 'Interactive demo for testing (no AI installation required)', website: 'https://termly.dev', checkInstalled: async () => true // Always available + }, + 'openclaude': { + key: 'openclaude', + command: 'openclaude', + args: [], + displayName: 'OpenClaude (Profile 1)', + description: 'Claude Code with custom API key profile 1', + website: 'https://docs.termly.dev', + checkInstalled: async () => await commandExists('openclaude') + }, + 'openclaude2': { + key: 'openclaude2', + command: 'openclaude2', + args: [], + displayName: 'OpenClaude (Profile 2)', + description: 'Claude Code with custom API key profile 2', + website: 'https://docs.termly.dev', + checkInstalled: async () => await commandExists('openclaude2') } }; From a722a4394b83af5a0e51d41bbaa87679ee71f90f Mon Sep 17 00:00:00 2001 From: B-A-M-N Date: Mon, 25 May 2026 20:16:35 -0500 Subject: [PATCH 7/8] feat: self-hosted termly server support, new AI tools - Add termly-local-server integration (server command for start/stop/launch) - Add crush, gemini (TUI), and vibe AI tools to registry - Fix local mode port to 3001 (avoid WhatsApp bridge conflict) - Use LAN IP in QR code for mobile connectivity - Auto-set TERMLY_ENV=local in launch flow - One-command launcher: termly-server start --- bin/cli.js | 9 ++ lib/ai-tools/registry.js | 36 ++++++++ lib/commands/batch.js | 2 +- lib/commands/server.js | 184 +++++++++++++++++++++++++++++++++++++ lib/commands/start.js | 28 +++++- lib/config/environment.js | 6 +- lib/session/pty-manager.js | 2 +- 7 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 lib/commands/server.js diff --git a/bin/cli.js b/bin/cli.js index 5af3091..41c48a2 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -14,6 +14,7 @@ const batchCommand = require('../lib/commands/batch'); const toolsCommand = require('../lib/commands/tools'); const configCommand = require('../lib/commands/config'); const cleanupCommand = require('../lib/commands/cleanup'); +const serverCommand = require('../lib/commands/server'); const program = new Command(); @@ -162,5 +163,13 @@ program.on('--help', () => { console.log(''); }); +// Server command — manage local termly server +program + .command('server [action]') + .description('Manage local Termly server (start/stop/status/launch)') + .action(async (action = 'start') => { + await serverCommand(action); + }); + // Parse arguments program.parse(process.argv); diff --git a/lib/ai-tools/registry.js b/lib/ai-tools/registry.js index 790d91b..8cbf2ee 100644 --- a/lib/ai-tools/registry.js +++ b/lib/ai-tools/registry.js @@ -29,6 +29,15 @@ const AI_TOOLS = { website: 'https://openai.com/codex', checkInstalled: async () => await commandExists('codex') }, + 'crush': { + key: 'crush', + command: 'crush', + args: [], + displayName: 'Crush', + description: 'Terminal-first AI coding assistant', + website: 'https://github.com/charmbracelet/crush', + checkInstalled: async () => await commandExists('crush') + }, 'github-copilot': { key: 'github-copilot', command: 'copilot', @@ -236,6 +245,15 @@ const AI_TOOLS = { website: 'https://gptme.org', checkInstalled: async () => await commandExists('gptme') }, + 'hermes': { + key: 'hermes', + command: 'hermes', + args: [], + displayName: 'Hermes Agent', + description: 'Self-improving AI agent by Nous Research with learning loop and multi-platform support', + website: 'https://hermes-agent.nousresearch.com', + checkInstalled: async () => await commandExists('hermes') + }, 'mods': { key: 'mods', command: 'mods', @@ -245,6 +263,15 @@ const AI_TOOLS = { website: 'https://github.com/charmbracelet/mods', checkInstalled: async () => await commandExists('mods') }, + 'vibe': { + key: 'vibe', + command: 'prvibe', + args: [], + displayName: 'Mistral Vibe', + description: 'Mistral Vibe PR workflow CLI', + website: 'https://github.com/mistralai/vibe', + checkInstalled: async () => await commandExists('prvibe') + }, 'qodo': { key: 'qodo', command: 'qodo', @@ -280,6 +307,15 @@ const AI_TOOLS = { description: 'Claude Code with custom API key profile 2', website: 'https://docs.termly.dev', checkInstalled: async () => await commandExists('openclaude2') + }, + 'openclaude3': { + key: 'openclaude3', + command: 'openclaude3', + args: [], + displayName: 'OpenClaude (Profile 3)', + description: 'Claude Code with custom API key profile 3', + website: 'https://docs.termly.dev', + checkInstalled: async () => await commandExists('openclaude3') } }; diff --git a/lib/commands/batch.js b/lib/commands/batch.js index 100573b..6973b33 100644 --- a/lib/commands/batch.js +++ b/lib/commands/batch.js @@ -6,7 +6,7 @@ const { validateDirectory } = require('../utils/validation'); const { displayPairingUI } = require('../utils/qr'); const logger = require('../utils/logger'); const { getToolByKey } = require('../ai-tools/registry'); -const { getServerUrl, getApiUrl, getEnvironmentName } = require('../config/environment'); +const { getServerUrl, getApiUrl, getEnvironmentName, isLocal } = require('../config/environment'); const { addSession, updateSession } = require('../session/registry'); const { createSession, SessionState } = require('../session/state'); const PTYManager = require('../session/pty-manager'); diff --git a/lib/commands/server.js b/lib/commands/server.js new file mode 100644 index 0000000..a73883a --- /dev/null +++ b/lib/commands/server.js @@ -0,0 +1,184 @@ +const { spawn, execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const logger = require('../utils/logger'); + +const DEFAULT_SERVER_DIR = path.resolve(__dirname, '../../termly-local-server'); + +function findServerDir() { + // Check default location first + if (fs.existsSync(path.join(DEFAULT_SERVER_DIR, 'server.js'))) { + return DEFAULT_SERVER_DIR; + } + // Check relative to termly-cli + const alt = path.resolve(__dirname, '../../../termly-local-server'); + if (fs.existsSync(path.join(alt, 'server.js'))) { + return alt; + } + return null; +} + +function getLanIp() { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +async function serverCommand(action = 'start') { + const serverDir = findServerDir(); + + if (!serverDir) { + console.error('termly-local-server not found.'); + console.error('Expected at: ' + DEFAULT_SERVER_DIR); + console.error('Clone it: git clone https://github.com/termly-dev/termly-cli termly-local-server'); + process.exit(1); + } + + const port = process.env.TERMLY_LOCAL_PORT || 3001; + const lanIp = getLanIp(); + + switch (action) { + case 'start': { + // Check if server is already running + try { + execSync(`lsof -i :${port} -t 2>/dev/null`, { stdio: 'pipe' }); + console.log(`Server already running on port ${port}`); + } catch { + // Port is free, start the server + console.log(`Starting Termly local server on port ${port}...`); + const serverProcess = spawn('node', ['server.js'], { + cwd: serverDir, + env: { ...process.env, TERMLY_LOCAL_PORT: String(port) }, + detached: true, + stdio: 'ignore' + }); + serverProcess.unref(); + + // Wait for server to be ready + let ready = false; + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 250)); + try { + execSync(`curl -s http://localhost:${port}/api/health > /dev/null 2>&1`, { stdio: 'pipe' }); + ready = true; + break; + } catch { + // not ready yet + } + } + + if (!ready) { + console.error('Server failed to start within 5 seconds.'); + process.exit(1); + } + } + + process.env.TERMLY_ENV = 'local'; + console.log(''); + console.log('========================================'); + console.log(' Termly Local Server running!'); + console.log('========================================'); + console.log(''); + console.log(` Local: http://localhost:${port}`); + console.log(` Network: http://${lanIp}:${port}`); + console.log(' Run: termly start'); + console.log('========================================'); + console.log(''); + break; + } + + case 'stop': { + try { + const pids = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: 'utf8' }).trim(); + if (pids) { + pids.split('\n').forEach(pid => { + try { process.kill(parseInt(pid)); } catch {} + }); + console.log(`Stopped server on port ${port}`); + } else { + console.log(`No server running on port ${port}`); + } + } catch { + console.log(`No server running on port ${port}`); + } + break; + } + + case 'status': { + try { + execSync(`lsof -i :${port} -t 2>/dev/null`, { stdio: 'pipe' }); + console.log(`Server running on port ${port}`); + console.log(` Local: http://localhost:${port}`); + console.log(` Network: http://${lanIp}:${port}`); + } catch { + console.log(`Server not running on port ${port}`); + console.log(`Start it: termly-server start`); + } + break; + } + + case 'launch': { + // Start server + termly start in one command + try { + execSync(`lsof -i :${port} -t 2>/dev/null`, { stdio: 'pipe' }); + console.log(`Server already running on port ${port}`); + } catch { + console.log(`Starting Termly local server on port ${port}...`); + const serverProcess = spawn('node', ['server.js'], { + cwd: serverDir, + env: { ...process.env, TERMLY_LOCAL_PORT: String(port) }, + detached: true, + stdio: 'ignore' + }); + serverProcess.unref(); + + let ready = false; + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 250)); + try { + execSync(`curl -s http://localhost:${port}/api/health > /dev/null 2>&1`, { stdio: 'pipe' }); + ready = true; + break; + } catch {} + } + if (!ready) { + console.error('Server failed to start.'); + process.exit(1); + } + console.log('Server ready!'); + } + + console.log(''); + console.log('========================================'); + console.log(' Termly Local Server running!'); + console.log('========================================'); + console.log(''); + console.log(` Local: http://localhost:${port}`); + console.log(` Network: http://${lanIp}:${port}`); + console.log('========================================'); + console.log(''); + console.log('Starting Termly CLI in local mode...'); + console.log(''); + + // Launch termly start with TERMLY_ENV=local + process.env.TERMLY_ENV = 'local'; + const termlyStart = require('./start'); + await termlyStart(process.cwd(), {}); + break; + } + + default: + console.error(`Unknown action: ${action}`); + console.error('Usage: termly server [start|stop|status|launch]'); + process.exit(1); + } +} + +module.exports = serverCommand; diff --git a/lib/commands/start.js b/lib/commands/start.js index 8b0bdfa..ddd94dd 100644 --- a/lib/commands/start.js +++ b/lib/commands/start.js @@ -6,7 +6,7 @@ const { validateDirectory } = require('../utils/validation'); const { displayPairingUI } = require('../utils/qr'); const logger = require('../utils/logger'); const { selectAITool } = require('../ai-tools/selector'); -const { getServerUrl, getApiUrl, getEnvironmentName } = require('../config/environment'); +const { getServerUrl, getApiUrl, getEnvironmentName, isLocal } = require('../config/environment'); const { getSessionByDirectory, addSession, updateSession } = require('../session/registry'); const { createSession, SessionState } = require('../session/state'); const PTYManager = require('../session/pty-manager'); @@ -153,12 +153,32 @@ async function startCommand(directory, options) { options.label ); + // Step 7b: For local mode, generate LAN-accessible server URL for mobile QR code + let qrServerUrl = serverUrl; + if (isLocal()) { + const os = require('os'); + const interfaces = os.networkInterfaces(); + let lanIp = '127.0.0.1'; + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + lanIp = iface.address; + break; + } + } + if (lanIp !== '127.0.0.1') break; + } + qrServerUrl = serverUrl.replace('localhost', lanIp).replace('127.0.0.1', lanIp); + logger.debug(`CLI serverUrl: ${serverUrl}`); + logger.debug(`QR serverUrl: ${qrServerUrl} (LAN IP)`); + } + // Step 8: Display QR code and pairing info displayPairingUI( pairingCode, - serverUrl, - selectedTool.key, // QR code tool identifier - selectedTool.displayName, // UI display name + qrServerUrl, + selectedTool.key, + selectedTool.displayName, selectedTool.version, projectName, require('os').hostname(), diff --git a/lib/config/environment.js b/lib/config/environment.js index 8596f22..fcff15b 100644 --- a/lib/config/environment.js +++ b/lib/config/environment.js @@ -2,10 +2,12 @@ const path = require('path'); const fs = require('fs'); // Environment configurations +const LOCAL_PORT = process.env.TERMLY_LOCAL_PORT || 3001; + const ENVIRONMENTS = { local: { - serverUrl: 'ws://localhost:3000', - apiUrl: 'http://localhost:3000', + serverUrl: `ws://localhost:${LOCAL_PORT}`, + apiUrl: `http://localhost:${LOCAL_PORT}`, name: 'Local Development' }, dev: { diff --git a/lib/session/pty-manager.js b/lib/session/pty-manager.js index 3af5cbe..ccfeaee 100644 --- a/lib/session/pty-manager.js +++ b/lib/session/pty-manager.js @@ -17,7 +17,7 @@ class PTYManager { this.outputPaused = false; // Flag to pause PTY output during reconnection // TUI tools that use alternate screen buffer (internal scroll, mouse events) - this.tuiTools = ['opencode', 'kilo']; + this.tuiTools = ['opencode', 'kilo', 'crush', 'gemini']; this.isTUIMode = this.tuiTools.includes(tool.key); // Deduplication state (for Windows cmd.exe duplicate output bug) From dcb61a05864554f5030e5c91df8bcc8124b62312 Mon Sep 17 00:00:00 2001 From: B-A-M-N Date: Mon, 25 May 2026 20:26:11 -0500 Subject: [PATCH 8/8] feat: bundle termly-local-server for self-hosting Include the local relay server in the fork so users can self-host termly without depending on the dead termly.dev service. Co-Authored-By: OWL <> --- termly-local-server/.gitignore | 1 + termly-local-server/README.md | 87 +++ termly-local-server/package-lock.json | 880 ++++++++++++++++++++++++++ termly-local-server/package.json | 18 + termly-local-server/server.js | 406 ++++++++++++ termly-local-server/start.sh | 35 + termly-local-server/termly-server.sh | 99 +++ 7 files changed, 1526 insertions(+) create mode 100644 termly-local-server/.gitignore create mode 100644 termly-local-server/README.md create mode 100644 termly-local-server/package-lock.json create mode 100644 termly-local-server/package.json create mode 100644 termly-local-server/server.js create mode 100755 termly-local-server/start.sh create mode 100755 termly-local-server/termly-server.sh diff --git a/termly-local-server/.gitignore b/termly-local-server/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/termly-local-server/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/termly-local-server/README.md b/termly-local-server/README.md new file mode 100644 index 0000000..230fb65 --- /dev/null +++ b/termly-local-server/README.md @@ -0,0 +1,87 @@ +# Termly Local Server +# Self-hosted replacement for termly.dev — run your own remote terminal relay + +## What This Is + +Termly CLI's official server (termly.dev) is dead. This is a local replacement +that implements the same WebSocket relay protocol so you can use the Termly +mobile app to control AI coding agents (Claude Code, Aider, etc.) from your +phone over your local network. + +## How It Works + +``` +Termly Mobile App <---wss---> THIS SERVER <---ws---> Termly CLI + (phone) LAN:3001 localhost:3001 (your machine) +``` + +The server is a dumb relay — all terminal data is E2EE (AES-256-GCM) between +the CLI and mobile app. The server never sees your data unencrypted. + +## Setup + +### 1. Start the server + +```bash +cd /home/bamn/termly-local-server +./start.sh +``` + +It listens on port 3001 by default. Set `TERMLY_LOCAL_PORT` to change it: + +```bash +TERMLY_LOCAL_PORT=4000 ./start.sh +``` + +### 2. Start Termly CLI in local mode + +```bash +TERMLY_ENV=local termly start +``` + +The CLI will: +- Register a pairing code with the local server +- Generate a QR code containing your LAN IP (so your phone can reach the server) +- Wait for the mobile app to connect + +### 3. Connect your phone + +Open the Termly mobile app, scan the QR code displayed in your terminal. +The app connects to your local server, does a DH key exchange (E2EE), +and you're in. + +## Port Conflict + +If port 3000 is free, you can revert the CLI to use port 3000 instead: + +```bash +cd /home/bamn/termly-cli +# Edit lib/config/environment.js: change PORT back to 3000 +cd /home/bamn/termly-local-server +TERMLY_LOCAL_PORT=3000 ./start.sh +``` + +The Hermes WhatsApp bridge uses port 3000 by default, so 3001 is the +safe default. + +## Files + +- `server.js` — the relay server (Express + ws) +- `start.sh` — convenience launcher +- `lib/config/environment.js` (in termly-cli) — updated local URL to :3001 +- `lib/commands/start.js` (in termly-cli) — LAN IP in QR code +- `lib/commands/batch.js` (in termly-cli) — same LAN IP fix + +## Protocol + +See `../termly-cli/COMMUNICATION_PROTOCOL.md` for the full wire protocol. +The server implements: + +- `POST /api/pairing` — register pairing code + DH public key +- `POST /api/pairing/batch` — batch registration (multi-agent) +- `GET /api/cli/version` — version check (always returns success) +- `GET /api/health` — health check +- `WS /ws/agent?code=XXX` — CLI WebSocket connection +- `WS /ws/agent?sessionId=XXX` — mobile WebSocket connection +- Heartbeat: server → CLI ping every 30s, CLI responds pong +- Message relay: all encrypted messages forwarded opaquely between CLI and mobile diff --git a/termly-local-server/package-lock.json b/termly-local-server/package-lock.json new file mode 100644 index 0000000..d3ac995 --- /dev/null +++ b/termly-local-server/package-lock.json @@ -0,0 +1,880 @@ +{ + "name": "termly-local-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "termly-local-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "express": "^5.2.1", + "uuid": "^14.0.0", + "ws": "^8.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/termly-local-server/package.json b/termly-local-server/package.json new file mode 100644 index 0000000..7cf9dad --- /dev/null +++ b/termly-local-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "termly-local-server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "express": "^5.2.1", + "uuid": "^14.0.0", + "ws": "^8.21.0" + } +} diff --git a/termly-local-server/server.js b/termly-local-server/server.js new file mode 100644 index 0000000..7e7b9ad --- /dev/null +++ b/termly-local-server/server.js @@ -0,0 +1,406 @@ +#!/usr/bin/env node + +const express = require('express'); +const { WebSocketServer, WebSocket } = require('ws'); +const http = require('http'); +const { v4: uuidv4 } = require('uuid'); + +const PORT = process.env.TERMLY_LOCAL_PORT || 3001; +const HEARTBEAT_INTERVAL = 30000; + +const app = express(); +app.use(express.json({ limit: '1mb' })); + +// --- Session registry --- +// Map +// Map +const sessionsByCode = new Map(); +const sessionsById = new Map(); + +class Session { + constructor({ code, apiKey, projectName, workingDir, computerName, aiTool, aiToolVersion, label }) { + this.sessionId = uuidv4(); + this.code = code; + this.apiKey = apiKey; + this.projectName = projectName; + this.workingDir = workingDir; + this.computerName = computerName; + this.aiTool = aiTool; + this.aiToolVersion = aiToolVersion; + this.label = label || null; + this.cliPublicKey = null; + this.mobilePublicKey = null; + this.cliWs = null; + this.mobileWs = null; + this.paired = false; + this.lastMobileSeq = 0; + this.createdAt = new Date().toISOString(); + this.tools = []; // for batch mode + } +} + +// --- REST API --- + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', termlyLocal: true, sessions: sessionsByCode.size }); +}); + +// Version check — always return success for local +app.get('/api/cli/version', (req, res) => { + const { currentVersion } = req.query; + res.json({ + currentVersion: currentVersion || '0.0.0', + minVersion: '0.0.0', + updateCommand: '', + isLatest: true, + local: true + }); +}); + +// Register pairing code (CLI → server) +app.post('/api/pairing', (req, res) => { + const { code, publicKey, projectName, workingDir, computerName, aiTool, aiToolVersion, label } = req.body; + + if (!code || !publicKey) { + return res.status(400).json({ error: 'Missing required fields', details: [{ field: !code ? 'code' : 'publicKey', message: 'Required' }] }); + } + + if (sessionsByCode.has(code)) { + return res.status(409).json({ error: 'Pairing code already registered' }); + } + + const session = new Session({ + code, apiKey: publicKey, projectName, workingDir, computerName, aiTool, aiToolVersion, label + }); + session.cliPublicKey = publicKey; + + sessionsByCode.set(code, session); + sessionsById.set(session.sessionId, session); + + console.log(`[pairing] Registered code=${code} sessionId=${session.sessionId} aiTool=${aiTool || 'unknown'} project=${projectName || '.'}`); + + res.json({ + success: true, + sessionId: session.sessionId, + message: 'Pairing code registered. Waiting for mobile connection.' + }); +}); + +// Batch pairing (fork feature) +app.post('/api/pairing/batch', (req, res) => { + const { code, publicKey, projectName, workingDir, computerName, tools } = req.body; + + if (!code || !publicKey) { + return res.status(400).json({ error: 'Missing required fields', details: [{ field: !code ? 'code' : 'publicKey', message: 'Required' }] }); + } + + if (sessionsByCode.has(code)) { + return res.status(409).json({ error: 'Pairing code already registered' }); + } + + const primaryTool = tools && tools.length > 0 ? tools[0] : {}; + const session = new Session({ + code, apiKey: publicKey, projectName, workingDir, computerName, + aiTool: primaryTool.aiTool || 'unknown', + aiToolVersion: primaryTool.aiToolVersion || '0.0.0' + }); + session.cliPublicKey = publicKey; + session.tools = tools || []; + + sessionsByCode.set(code, session); + sessionsById.set(session.sessionId, session); + + console.log(`[pairing] Batch registered code=${code} sessionId=${session.sessionId} tools=${session.tools.length}`); + + res.json({ + success: true, + sessionId: session.sessionId, + message: `Registered ${session.tools.length} tools. Waiting for mobile connection.` + }); +}); + +// --- WebSocket Server --- + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws/agent' }); + +wss.on('connection', (ws, req) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const code = url.searchParams.get('code'); + const sessionId = url.searchParams.get('sessionId'); + + if (!code && !sessionId) { + ws.close(4001, 'Missing code or sessionId'); + return; + } + + // Resolve session + let session = null; + if (code) { + session = sessionsByCode.get(code); + if (session) { + console.log(`[ws] CLI connected: code=${code} sessionId=${session.sessionId}`); + session.cliWs = ws; + } + } else if (sessionId) { + session = sessionsById.get(sessionId); + if (session) { + console.log(`[ws] Client connected via sessionId=${sessionId}`); + // Could be CLI reconnect or mobile — check below + } + } + + if (!session) { + ws.close(4004, 'Session not found'); + return; + } + + // Assign a peer ID to this connection + const peerId = uuidv4().slice(0, 8); + + // If this session has no CLI yet, and this connection used a `code` param, + // treat it as the CLI. Otherwise, defer classification until first message. + if (code && !sessionId && !session.cliWs) { + session.cliWs = ws; + ws._isCli = true; + ws._peerId = peerId; + console.log(`[ws] CLI connected: code=${code} sessionId=${session.sessionId} peer=${peerId}`); + } else { + // Defer — could be CLI reconnect or mobile. Set as pending. + ws._isCli = null; // unknown until first message + ws._peerId = peerId; + ws._deferredSessionId = session.sessionId; + console.log(`[ws] Peer connected: sessionId=${session.sessionId} peer=${peerId} (deferred)`); + + // Track deferred connection on session + if (!session._deferred) session._deferred = []; + session._deferred.push(ws); + } + + // --- Message handling --- + ws.on('message', (rawData) => { + try { + const msg = JSON.parse(rawData.toString()); + handleMessage(session, ws, msg); + } catch (e) { + // Could be binary — just relay it + } + }); + + ws.on('close', () => { + handleDisconnect(session, ws); + }); + + ws.on('error', (err) => { + console.error(`[ws] Error on session ${session.sessionId}:`, err.message); + }); + + // Heartbeat ping (only to CLI) + if (ws === session.cliWs) { + startHeartbeat(session); + } +}); + +function handleMessage(session, ws, msg) { + // --- Deferred connection classification --- + // If this connection hasn't been classified yet (ws._isCli === null), + // classify it based on the first message's content. + if (ws._isCli === null) { + ws._isCli = false; // assume mobile unless proven otherwise + if (msg.type === 'output' || msg.type === 'sync_complete' || msg.type === 'pong') { + // These are CLI-only message types + ws._isCli = true; + } + // If it's a CLI, set cliWs; if mobile, set mobileWs + if (ws._isCli) { + session.cliWs = ws; + console.log(`[ws] Deferred peer classified as CLI: peer=${ws._peerId}`); + } else { + session.mobileWs = ws; + console.log(`[ws] Mobile connected: peer=${ws._peerId} sessionId=${session.sessionId}`); + // Notify CLI that mobile is connected + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify({ + type: 'client_connected', + timestamp: new Date().toISOString() + })); + } + } + // Remove from deferred list + if (session._deferred) { + session._deferred = session._deferred.filter(d => d !== ws); + } + } + + switch (msg.type) { + case 'pong': + // CLI responding to our ping — connection alive + session._lastPong = Date.now(); + break; + + case 'output': + // CLI → Mobile (encrypted terminal output) + // Forward to mobile if connected + if (session.mobileWs && session.mobileWs.readyState === WebSocket.OPEN) { + session.mobileWs.send(JSON.stringify(msg)); + // Track last seq mobile received + if (msg.seq && msg.seq > session.lastMobileSeq) { + session.lastMobileSeq = msg.seq; + } + } + break; + + case 'input': + // Mobile → CLI (encrypted user input) + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify(msg)); + } + break; + + case 'resize': + // Mobile → CLI (terminal resize) + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify(msg)); + } + break; + + case 'sync_complete': + // CLI → Mobile (catchup done) + if (session.mobileWs && session.mobileWs.readyState === WebSocket.OPEN) { + session.mobileWs.send(JSON.stringify(msg)); + } + break; + + case 'mobile_pairing': + // Mobile → Server → CLI (DH public key from mobile) + session.mobilePublicKey = msg.publicKey; + session.paired = true; + console.log(`[pairing] Mobile paired with session ${session.sessionId}`); + + // Forward to CLI as pairing_complete with session info + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify({ + type: 'pairing_complete', + sessionId: session.sessionId, + mobilePublicKey: msg.publicKey, + timestamp: new Date().toISOString() + })); + } + + // Also notify mobile + if (session.mobileWs && session.mobileWs.readyState === WebSocket.OPEN) { + session.mobileWs.send(JSON.stringify({ + type: 'pairing_ack', + sessionId: session.sessionId, + publicKey: session.cliPublicKey, // CLI's public key for mobile to compute shared secret + timestamp: new Date().toISOString() + })); + } + break; + + case 'catchup_request': + // Mobile → Server → CLI (requesting missed messages) + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify({ + type: 'catchup_request', + lastSeq: msg.lastSeq || 0, + timestamp: new Date().toISOString() + })); + } + break; + + default: + // Unknown message type — relay it blindly (future-proofing) + const target = ws._isCli ? session.mobileWs : session.cliWs; + if (target && target.readyState === WebSocket.OPEN) { + target.send(JSON.stringify(msg)); + } + break; + } +} + +function handleDisconnect(session, ws) { + // Remove from deferred list if still there + if (session._deferred) { + session._deferred = session._deferred.filter(d => d !== ws); + } + + if (ws._isCli === true || ws === session.cliWs) { + console.log(`[ws] CLI disconnected: sessionId=${session.sessionId}`); + session.cliWs = null; + + // Notify mobile that CLI disconnected + if (session.mobileWs && session.mobileWs.readyState === WebSocket.OPEN) { + session.mobileWs.send(JSON.stringify({ + type: 'cli_disconnected', + timestamp: new Date().toISOString() + })); + } + } else if (ws._isCli === false || ws === session.mobileWs) { + console.log(`[ws] Mobile disconnected: sessionId=${session.sessionId}`); + session.mobileWs = null; + + // Notify CLI that mobile disconnected + if (session.cliWs && session.cliWs.readyState === WebSocket.OPEN) { + session.cliWs.send(JSON.stringify({ + type: 'client_disconnected', + timestamp: new Date().toISOString() + })); + } + } + + // Cleanup session if all sides disconnected + if (!session.cliWs && !session.mobileWs) { + setTimeout(() => { + if (!session.cliWs && !session.mobileWs) { + sessionsByCode.delete(session.code); + sessionsById.delete(session.sessionId); + console.log(`[cleanup] Session ${session.sessionId} removed`); + } + }, 5 * 60 * 1000); + } +} + +// --- Heartbeat --- +function startHeartbeat(session) { + if (session._heartbeatTimer) return; + + session._heartbeatTimer = setInterval(() => { + if (!session.cliWs || session.cliWs.readyState !== WebSocket.OPEN) { + clearInterval(session._heartbeatTimer); + session._heartbeatTimer = null; + return; + } + + const lastPong = session._lastPong || 0; + if (Date.now() - lastPong > HEARTBEAT_INTERVAL * 3) { + console.log(`[heartbeat] CLI timed out on session ${session.sessionId}`); + session.cliWs.close(4008, 'Heartbeat timeout'); + return; + } + + session.cliWs.send(JSON.stringify({ type: 'ping' })); + }, HEARTBEAT_INTERVAL); +} + +// --- Start --- +server.listen(PORT, '0.0.0.0', () => { + console.log(`[termly-local] Server running on ws://0.0.0.0:${PORT}`); + console.log(`[termly-local] Health: http://localhost:${PORT}/api/health`); + console.log(`[termly-local] QR serverUrl: ws://:${PORT}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\n[termly-local] Shutting down...'); + wss.close(); + server.close(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + wss.close(); + server.close(); + process.exit(0); +}); diff --git a/termly-local-server/start.sh b/termly-local-server/start.sh new file mode 100755 index 0000000..d7ed90c --- /dev/null +++ b/termly-local-server/start.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Start Termly Local Server +# Usage: ./start.sh +# +# To use with Termly CLI: +# TERMLY_ENV=local termly start +# +# Or with custom port: +# TERMLY_LOCAL_PORT=4000 ./start.sh +# TERMLY_ENV=local TERMLY_LOCAL_PORT=4000 termly start + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +PORT="${TERMLY_LOCAL_PORT:-3001}" +MY_IP=$(hostname -I | awk '{print $1}') + +if [ ! -d node_modules ]; then + echo "Installing dependencies..." + npm install +fi + +echo "==================================" +echo " Termly Local Server" +echo "==================================" +echo "" +echo " Local: ws://localhost:${PORT}" +echo " Network: ws://${MY_IP}:${PORT}" +echo " Health: http://localhost:${PORT}/api/health" +echo "" +echo " CLI usage: TERMLY_ENV=local termly start" +echo "==================================" +echo "" + +TERMLY_LOCAL_PORT="${PORT}" node server.js diff --git a/termly-local-server/termly-server.sh b/termly-local-server/termly-server.sh new file mode 100755 index 0000000..89b3fe7 --- /dev/null +++ b/termly-local-server/termly-server.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# termly-server — one-command launcher for Termly local + CLI +# +# Usage: +# termly-server # Start server + launch termly (default) +# termly-server start # Same as above +# termly-server stop # Stop just the server +# termly-server status # Check if server is running +# termly-server restart # Stop + start + launch +# +# Environment: +# TERMLY_LOCAL_PORT Port to use (default: 3001) + +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +PORT="${TERMLY_LOCAL_PORT:-3001}" +ACTION="${1:-start}" +shift || true + +case "$ACTION" in + start|"") + # Start the background server + if lsof -i ":${PORT}" -t >/dev/null 2>&1; then + echo "Server already running on port ${PORT}" + else + echo "Starting Termly local server on port ${PORT}..." + TERMLY_LOCAL_PORT="${PORT}" node "${SCRIPT_DIR}/server.js" & + SERVER_PID=$! + + # Wait for server ready + for i in $(seq 1 20); do + sleep 0.5 + if curl -s "http://localhost:${PORT}/api/health" >/dev/null 2>&1; then + break + fi + done + + MY_IP=$(hostname -I | awk '{print $1}') + echo "" + echo "========================================" + echo " Termly Local Server running!" + echo "========================================" + echo "" + echo " Local: ws://localhost:${PORT}" + echo " Network: ws://${MY_IP}:${PORT}" + echo " Health: http://localhost:${PORT}/api/health" + echo "" + fi + + # Now launch termly CLI in local mode + echo "Starting Termly CLI in local mode..." + echo " (Ctrl+C stops the CLI. Server keeps running.)" + echo "" + export TERMLY_ENV=local + export TERMLY_LOCAL_PORT="${PORT}" + exec termly start "$@" + ;; + + stop) + PID=$(lsof -i ":${PORT}" -t 2>/dev/null) + if [ -n "$PID" ]; then + kill $PID 2>/dev/null && echo "Stopped server on port ${PORT}" || echo "Failed to stop" + else + echo "No server running on port ${PORT}" + fi + ;; + + status) + PID=$(lsof -i ":${PORT}" -t 2>/dev/null) + MY_IP=$(hostname -I | awk '{print $1}') + if [ -n "$PID" ]; then + echo "Server running on port ${PORT} (PID: ${PID})" + echo " Local: ws://localhost:${PORT}" + echo " Network: ws://${MY_IP}:${PORT}" + else + echo "Server not running on port ${PORT}" + echo "Start it: termly-server start" + fi + ;; + + restart) + $0 stop 2>/dev/null + sleep 1 + exec $0 start "$@" + ;; + + help|--help|-h) + echo "Usage: termly-server [action]" + echo "" + echo "Actions:" + echo " (none) Start server + launch termly CLI (default)" + echo " start Same as default" + echo " stop Stop the local server" + echo " status Check server status" + echo " restart Stop + start + launch" + echo "" + echo "Environment:" + echo " TERMLY_LOCAL_PORT Port to use (default: 3001)" + ;; +esac