vibetunnel
Version:
Terminal sharing server with web interface - supports macOS, Linux, and headless environments
998 lines • 193 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSessionRoutes = createSessionRoutes;
exports.requestTerminalSpawn = requestTerminalSpawn;
const chalk_1 = __importDefault(require("chalk"));
const express_1 = require("express");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const util_1 = require("util");
const terminal_text_formatter_js_1 = require("../../shared/terminal-text-formatter.js");
const types_js_1 = require("../../shared/types.js");
const index_js_1 = require("../pty/index.js");
const tailscale_serve_service_js_1 = require("../services/tailscale-serve-service.js");
const git_info_js_1 = require("../utils/git-info.js");
const git_status_js_1 = require("../utils/git-status.js");
const logger_js_1 = require("../utils/logger.js");
const path_utils_js_1 = require("../utils/path-utils.js");
const session_naming_js_1 = require("../utils/session-naming.js");
const control_protocol_js_1 = require("../websocket/control-protocol.js");
const control_unix_handler_js_1 = require("../websocket/control-unix-handler.js");
const logger = (0, logger_js_1.createLogger)('sessions');
const _execFile = (0, util_1.promisify)(require('child_process').execFile);
// Helper function to resolve path with default fallback
function resolvePath(inputPath, defaultPath) {
if (!inputPath || inputPath.trim() === '') {
return defaultPath;
}
// Use our utility function to handle tilde expansion and absolute path resolution
const expanded = (0, path_utils_js_1.resolveAbsolutePath)(inputPath);
// If the input was relative (not starting with / or ~), resolve it relative to defaultPath
if (!inputPath.startsWith('/') && !inputPath.startsWith('~')) {
return path.join(defaultPath, inputPath);
}
return expanded;
}
function createSessionRoutes(config) {
const router = (0, express_1.Router)();
const { ptyManager, terminalManager, streamWatcher, remoteRegistry, isHQMode, activityMonitor } = config;
// Server status endpoint
router.get('/server/status', async (_req, res) => {
logger.debug('[GET /server/status] Getting server status');
try {
const status = {
macAppConnected: control_unix_handler_js_1.controlUnixHandler.isMacAppConnected(),
isHQMode,
version: process.env.VERSION || 'unknown',
};
res.json(status);
}
catch (error) {
logger.error('Failed to get server status:', error);
res.status(500).json({ error: 'Failed to get server status' });
}
});
// Tailscale Serve status endpoint
router.get('/sessions/tailscale/status', async (_req, res) => {
logger.debug('[GET /sessions/tailscale/status] Getting Tailscale Serve status');
try {
const status = await tailscale_serve_service_js_1.tailscaleServeService.getStatus();
res.json(status);
}
catch (error) {
logger.error('Failed to get Tailscale Serve status:', error);
res.status(500).json({ error: 'Failed to get Tailscale Serve status' });
}
});
// List all sessions (aggregate local + remote in HQ mode)
router.get('/sessions', async (_req, res) => {
logger.debug('[GET /sessions] Listing all sessions');
try {
let allSessions = [];
// Get local sessions
const localSessions = ptyManager.listSessions();
logger.debug(`[GET /sessions] Found ${localSessions.length} local sessions`);
// Log session names for debugging
// localSessions.forEach((session) => {
// logger.debug(
// `[GET /sessions] Session ${session.id}: name="${session.name || 'null'}", workingDir="${session.workingDir}"`
// );
// });
// Add source info to local sessions and detect Git info if missing
const localSessionsWithSource = await Promise.all(localSessions.map(async (session) => {
// If session doesn't have Git info, try to detect it
if (!session.gitRepoPath && session.workingDir) {
try {
const gitInfo = await (0, git_info_js_1.detectGitInfo)(session.workingDir);
// logger.debug(
// `[GET /sessions] Detected Git info for session ${session.id}: repo=${gitInfo.gitRepoPath}, branch=${gitInfo.gitBranch}`
// );
return {
...session,
...gitInfo,
source: 'local',
};
}
catch (error) {
// If Git detection fails, just return session as-is
logger.debug(`[GET /sessions] Could not detect Git info for session ${session.id}: ${error}`);
}
}
return {
...session,
source: 'local',
};
}));
allSessions = [...localSessionsWithSource];
// If in HQ mode, aggregate sessions from all remotes
if (isHQMode && remoteRegistry) {
const remotes = remoteRegistry.getRemotes();
logger.debug(`checking ${remotes.length} remote servers for sessions`);
// Fetch sessions from each remote in parallel
const remotePromises = remotes.map(async (remote) => {
try {
const response = await fetch(`${remote.url}/api/sessions`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000), // 5 second timeout
});
if (response.ok) {
const remoteSessions = (await response.json());
logger.debug(`got ${remoteSessions.length} sessions from remote ${remote.name}`);
// Track session IDs for this remote
const sessionIds = remoteSessions.map((s) => s.id);
remoteRegistry.updateRemoteSessions(remote.id, sessionIds);
// Add remote info to each session
return remoteSessions.map((session) => ({
...session,
source: 'remote',
remoteId: remote.id,
remoteName: remote.name,
remoteUrl: remote.url,
}));
}
else {
logger.warn(`failed to get sessions from remote ${remote.name}: HTTP ${response.status}`);
return [];
}
}
catch (error) {
logger.error(`failed to get sessions from remote ${remote.name}:`, error);
return [];
}
});
const remoteResults = await Promise.all(remotePromises);
const remoteSessions = remoteResults.flat();
logger.debug(`total remote sessions: ${remoteSessions.length}`);
allSessions = [...allSessions, ...remoteSessions];
}
logger.debug(`returning ${allSessions.length} total sessions`);
res.json(allSessions);
}
catch (error) {
logger.error('error listing sessions:', error);
res.status(500).json({ error: 'Failed to list sessions' });
}
});
// Create new session (local or on remote)
router.post('/sessions', async (req, res) => {
const { command, workingDir, name, remoteId, spawn_terminal, cols, rows, titleMode } = req.body;
logger.debug(`creating new session: command=${JSON.stringify(command)}, remoteId=${remoteId || 'local'}, spawn_terminal=${spawn_terminal}, cols=${cols}, rows=${rows}`);
if (!command || !Array.isArray(command) || command.length === 0) {
logger.warn('session creation failed: invalid command array');
return res.status(400).json({ error: 'Command array is required' });
}
try {
// If remoteId is specified and we're in HQ mode, forward to remote
if (remoteId && isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemote(remoteId);
if (!remote) {
logger.warn(`session creation failed: remote ${remoteId} not found`);
return res.status(404).json({ error: 'Remote server not found' });
}
logger.log(chalk_1.default.blue(`forwarding session creation to remote ${remote.name}`));
// Forward the request to the remote server
const startTime = Date.now();
const response = await fetch(`${remote.url}/api/sessions`, {
method: types_js_1.HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${remote.token}`,
},
body: JSON.stringify({
command,
workingDir,
name,
spawn_terminal,
cols,
rows,
titleMode,
// Don't forward remoteId to avoid recursion
}),
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
return res.status(response.status).json(error);
}
const result = (await response.json());
logger.debug(`remote session creation took ${Date.now() - startTime}ms`);
// Track the session in the remote's sessionIds
if (result.sessionId) {
remoteRegistry.addSessionToRemote(remote.id, result.sessionId);
}
res.json(result); // Return sessionId as-is, no namespacing
return;
}
// If spawn_terminal is true, use the control socket for terminal spawning
if (spawn_terminal) {
try {
// Generate session ID
const sessionId = generateSessionId();
const resolvedCwd = resolvePath(workingDir, process.cwd());
const sessionName = name || (0, session_naming_js_1.generateSessionName)(command, resolvedCwd);
// Detect Git information for terminal spawn
const gitInfo = await (0, git_info_js_1.detectGitInfo)(resolvedCwd);
// Request Mac app to spawn terminal
logger.log(chalk_1.default.blue(`requesting terminal spawn with command: ${JSON.stringify(command)}`));
const spawnResult = await requestTerminalSpawn({
sessionId,
sessionName,
command,
workingDir: resolvedCwd,
titleMode,
gitRepoPath: gitInfo.gitRepoPath,
gitBranch: gitInfo.gitBranch,
gitAheadCount: gitInfo.gitAheadCount,
gitBehindCount: gitInfo.gitBehindCount,
gitHasChanges: gitInfo.gitHasChanges,
gitIsWorktree: gitInfo.gitIsWorktree,
gitMainRepoPath: gitInfo.gitMainRepoPath,
});
if (!spawnResult.success) {
// Log the error but continue with fallback
logger.warn('terminal spawn failed:', spawnResult.error || 'Unknown error');
logger.debug('falling back to normal web session');
}
else {
// Wait a bit for the session to be created
await new Promise((resolve) => setTimeout(resolve, 500));
// Return the session ID - client will poll for the session to appear
logger.log(chalk_1.default.green(`terminal spawn requested for session ${sessionId}`));
res.json({ sessionId, message: 'Terminal spawn requested' });
return;
}
}
catch (error) {
// Log the error but continue with fallback
logger.error('error spawning terminal:', error);
logger.debug('falling back to normal web session');
}
}
// Create local session
let cwd = resolvePath(workingDir, process.cwd());
// Check if the working directory exists, fall back to process.cwd() if not
if (!fs.existsSync(cwd)) {
logger.warn(`Working directory '${cwd}' does not exist, using current directory as fallback`);
cwd = process.cwd();
}
const sessionName = name || (0, session_naming_js_1.generateSessionName)(command, cwd);
// Detect Git information
const gitInfo = await (0, git_info_js_1.detectGitInfo)(cwd);
logger.log(chalk_1.default.blue(`creating WEB session: ${command.join(' ')} in ${cwd} (spawn_terminal=${spawn_terminal})`));
const result = await ptyManager.createSession(command, {
name: sessionName,
workingDir: cwd,
cols,
rows,
titleMode,
gitRepoPath: gitInfo.gitRepoPath,
gitBranch: gitInfo.gitBranch,
gitAheadCount: gitInfo.gitAheadCount,
gitBehindCount: gitInfo.gitBehindCount,
gitHasChanges: gitInfo.gitHasChanges,
gitIsWorktree: gitInfo.gitIsWorktree,
gitMainRepoPath: gitInfo.gitMainRepoPath,
});
const { sessionId, sessionInfo } = result;
logger.log(chalk_1.default.green(`WEB session ${sessionId} created (PID: ${sessionInfo.pid})`));
// Stream watcher is set up when clients connect to the stream endpoint
res.json({ sessionId });
}
catch (error) {
logger.error('error creating session:', error);
if (error instanceof index_js_1.PtyError) {
res.status(500).json({ error: 'Failed to create session', details: error.message });
}
else {
res.status(500).json({ error: 'Failed to create session' });
}
}
});
// Get activity status for all sessions
router.get('/sessions/activity', async (_req, res) => {
logger.debug('getting activity status for all sessions');
try {
const activityStatus = {};
// Get local sessions activity
const localActivity = activityMonitor.getActivityStatus();
Object.assign(activityStatus, localActivity);
// If in HQ mode, get activity from remote servers
if (isHQMode && remoteRegistry) {
const remotes = remoteRegistry.getRemotes();
// Fetch activity from each remote in parallel
const remotePromises = remotes.map(async (remote) => {
try {
const response = await fetch(`${remote.url}/api/sessions/activity`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const remoteActivity = await response.json();
return {
remote: {
id: remote.id,
name: remote.name,
url: remote.url,
},
activity: remoteActivity,
};
}
}
catch (error) {
logger.error(`failed to get activity from remote ${remote.name}:`, error);
}
return null;
});
const remoteResults = await Promise.all(remotePromises);
// Merge remote activity data
for (const result of remoteResults) {
if (result?.activity) {
// Merge remote activity data
Object.assign(activityStatus, result.activity);
}
}
}
res.json(activityStatus);
}
catch (error) {
logger.error('error getting activity status:', error);
res.status(500).json({ error: 'Failed to get activity status' });
}
});
// Get activity status for a specific session
router.get('/sessions/:sessionId/activity', async (req, res) => {
const sessionId = req.params.sessionId;
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/activity`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to get activity from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const activityStatus = activityMonitor.getSessionActivityStatus(sessionId);
if (!activityStatus) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(activityStatus);
}
catch (error) {
logger.error(`error getting activity status for session ${sessionId}:`, error);
res.status(500).json({ error: 'Failed to get activity status' });
}
});
// Get git status for a specific session
router.get('/sessions/:sessionId/git-status', async (req, res) => {
const sessionId = req.params.sessionId;
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/git-status`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to get git status from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get detailed git status for the session's working directory
const gitStatus = await (0, git_status_js_1.getDetailedGitStatus)(session.workingDir);
res.json(gitStatus);
}
catch (error) {
logger.error(`error getting git status for session ${sessionId}:`, error);
res.status(500).json({ error: 'Failed to get git status' });
}
});
// Get single session info
router.get('/sessions/:sessionId', async (req, res) => {
const sessionId = req.params.sessionId;
logger.debug(`getting info for session ${sessionId}`);
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to get session info from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// If session doesn't have Git info, try to detect it
if (!session.gitRepoPath && session.workingDir) {
try {
const gitInfo = await (0, git_info_js_1.detectGitInfo)(session.workingDir);
// logger.debug(
// `[GET /sessions/:id] Detected Git info for session ${session.id}: repo=${gitInfo.gitRepoPath}, branch=${gitInfo.gitBranch}`
// );
res.json({ ...session, ...gitInfo });
return;
}
catch (error) {
// If Git detection fails, just return session as-is
logger.debug(`[GET /sessions/:id] Could not detect Git info for session ${session.id}: ${error}`);
}
}
res.json(session);
}
catch (error) {
logger.error('error getting session info:', error);
res.status(500).json({ error: 'Failed to get session info' });
}
});
// Kill session (just kill the process)
router.delete('/sessions/:sessionId', async (req, res) => {
const sessionId = req.params.sessionId;
logger.debug(`killing session ${sessionId}`);
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward kill request to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}`, {
method: types_js_1.HttpMethod.DELETE,
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
// Remote killed the session, now update our registry
remoteRegistry.removeSessionFromRemote(sessionId);
logger.log(chalk_1.default.yellow(`remote session ${sessionId} killed on ${remote.name}`));
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to kill session on remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling - just kill it, no registry updates needed
const session = ptyManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// If session is already exited, clean it up instead of trying to kill it
if (session.status === 'exited') {
ptyManager.cleanupSession(sessionId);
logger.log(chalk_1.default.yellow(`local session ${sessionId} cleaned up`));
res.json({ success: true, message: 'Session cleaned up' });
}
else {
// Check if this is a tmux attachment before killing
const isTmuxAttachment = session.name?.startsWith('tmux:') || session.command?.includes('tmux attach');
await ptyManager.killSession(sessionId, 'SIGTERM');
if (isTmuxAttachment) {
logger.log(chalk_1.default.yellow(`local session ${sessionId} detached from tmux`));
res.json({ success: true, message: 'Detached from tmux session' });
}
else {
logger.log(chalk_1.default.yellow(`local session ${sessionId} killed`));
res.json({ success: true, message: 'Session killed' });
}
}
}
catch (error) {
logger.error('error killing session:', error);
if (error instanceof index_js_1.PtyError) {
res.status(500).json({ error: 'Failed to kill session', details: error.message });
}
else {
res.status(500).json({ error: 'Failed to kill session' });
}
}
});
// Cleanup session files
router.delete('/sessions/:sessionId/cleanup', async (req, res) => {
const sessionId = req.params.sessionId;
logger.debug(`cleaning up session ${sessionId} files`);
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward cleanup request to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/cleanup`, {
method: types_js_1.HttpMethod.DELETE,
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
// Remote cleaned up the session, now update our registry
remoteRegistry.removeSessionFromRemote(sessionId);
logger.log(chalk_1.default.yellow(`remote session ${sessionId} cleaned up on ${remote.name}`));
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to cleanup session on remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling - just cleanup, no registry updates needed
ptyManager.cleanupSession(sessionId);
logger.log(chalk_1.default.yellow(`local session ${sessionId} cleaned up`));
res.json({ success: true, message: 'Session cleaned up' });
}
catch (error) {
logger.error('error cleaning up session:', error);
if (error instanceof index_js_1.PtyError) {
res.status(500).json({ error: 'Failed to cleanup session', details: error.message });
}
else {
res.status(500).json({ error: 'Failed to cleanup session' });
}
}
});
// Cleanup all exited sessions (local and remote)
router.post('/cleanup-exited', async (_req, res) => {
logger.log(chalk_1.default.blue('cleaning up all exited sessions'));
try {
// Clean up local sessions
const localCleanedSessions = ptyManager.cleanupExitedSessions();
logger.log(chalk_1.default.green(`cleaned up ${localCleanedSessions.length} local exited sessions`));
// Remove cleaned local sessions from remote registry if in HQ mode
if (isHQMode && remoteRegistry) {
for (const sessionId of localCleanedSessions) {
remoteRegistry.removeSessionFromRemote(sessionId);
}
}
let totalCleaned = localCleanedSessions.length;
const remoteResults = [];
// If in HQ mode, clean up sessions on all remotes
if (isHQMode && remoteRegistry) {
const allRemotes = remoteRegistry.getRemotes();
// Clean up on each remote in parallel
const remoteCleanupPromises = allRemotes.map(async (remote) => {
try {
const response = await fetch(`${remote.url}/api/cleanup-exited`, {
method: types_js_1.HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (response.ok) {
const result = (await response.json());
const cleanedSessionIds = result.cleanedSessions || [];
const cleanedCount = cleanedSessionIds.length;
totalCleaned += cleanedCount;
// Remove cleaned remote sessions from registry
for (const sessionId of cleanedSessionIds) {
remoteRegistry.removeSessionFromRemote(sessionId);
}
remoteResults.push({ remoteName: remote.name, cleaned: cleanedCount });
}
else {
throw new Error(`HTTP ${response.status}`);
}
}
catch (error) {
logger.error(`failed to cleanup sessions on remote ${remote.name}:`, error);
remoteResults.push({
remoteName: remote.name,
cleaned: 0,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
await Promise.all(remoteCleanupPromises);
}
res.json({
success: true,
message: `${totalCleaned} exited sessions cleaned up across all servers`,
localCleaned: localCleanedSessions.length,
remoteResults,
});
}
catch (error) {
logger.error('error cleaning up exited sessions:', error);
if (error instanceof index_js_1.PtyError) {
res
.status(500)
.json({ error: 'Failed to cleanup exited sessions', details: error.message });
}
else {
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
}
}
});
// Get session plain text
router.get('/sessions/:sessionId/text', async (req, res) => {
const sessionId = req.params.sessionId;
const includeStyles = req.query.styles !== undefined;
logger.debug(`getting plain text for session ${sessionId}, styles=${includeStyles}`);
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward text request to remote server
try {
const url = new URL(`${remote.url}/api/sessions/${sessionId}/text`);
if (includeStyles) {
url.searchParams.set('styles', '');
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
// Forward the text response
const text = await response.text();
res.setHeader('Content-Type', 'text/plain');
return res.send(text);
}
catch (error) {
logger.error(`failed to get text from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get terminal buffer snapshot
const snapshot = await terminalManager.getBufferSnapshot(sessionId);
// Use shared formatter to convert cells to text
const plainText = (0, terminal_text_formatter_js_1.cellsToText)(snapshot.cells, includeStyles);
// Send as plain text
res.setHeader('Content-Type', 'text/plain');
res.send(plainText);
}
catch (error) {
logger.error('error getting plain text:', error);
res.status(500).json({ error: 'Failed to get terminal text' });
}
});
// Get session buffer
router.get('/sessions/:sessionId/buffer', async (req, res) => {
const sessionId = req.params.sessionId;
logger.debug(`client requesting buffer for session ${sessionId}`);
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward buffer request to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/buffer`, {
headers: {
Authorization: `Bearer ${remote.token}`,
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
// Forward the binary buffer
const buffer = await response.arrayBuffer();
res.setHeader('Content-Type', 'application/octet-stream');
return res.send(Buffer.from(buffer));
}
catch (error) {
logger.error(`failed to get buffer from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
logger.error(`session ${sessionId} not found`);
return res.status(404).json({ error: 'Session not found' });
}
// Get terminal buffer snapshot
const snapshot = await terminalManager.getBufferSnapshot(sessionId);
// Encode as binary buffer
const buffer = terminalManager.encodeSnapshot(snapshot);
logger.debug(`sending buffer for session ${sessionId}: ${buffer.length} bytes, ` +
`dimensions: ${snapshot.cols}x${snapshot.rows}, cursor: (${snapshot.cursorX},${snapshot.cursorY})`);
// Send as binary data
res.setHeader('Content-Type', 'application/octet-stream');
res.send(buffer);
}
catch (error) {
logger.error('error getting buffer:', error);
res.status(500).json({ error: 'Failed to get terminal buffer' });
}
});
// Stream session output
router.get('/sessions/:sessionId/stream', async (req, res) => {
const sessionId = req.params.sessionId;
const startTime = Date.now();
logger.log(chalk_1.default.blue(`new SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`));
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Proxy SSE stream from remote server
try {
const controller = new AbortController();
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/stream`, {
headers: {
Authorization: `Bearer ${remote.token}`,
Accept: 'text/event-stream',
},
signal: controller.signal,
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
// Set up SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'X-Accel-Buffering': 'no',
});
// Proxy the stream
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
const bytesProxied = { count: 0 };
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
bytesProxied.count += value.length;
const chunk = decoder.decode(value, { stream: true });
res.write(chunk);
}
}
catch (error) {
logger.error(`stream proxy error for remote ${remote.name}:`, error);
}
};
pump();
// Clean up on disconnect
req.on('close', () => {
logger.log(chalk_1.default.yellow(`SSE client disconnected from remote session ${sessionId} (proxied ${bytesProxied.count} bytes)`));
controller.abort();
});
return;
}
catch (error) {
logger.error(`failed to stream from remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const sessionPaths = ptyManager.getSessionPaths(sessionId);
if (!sessionPaths) {
return res.status(404).json({ error: 'Session paths not found' });
}
const streamPath = sessionPaths.stdoutPath;
if (!streamPath || !fs.existsSync(streamPath)) {
logger.warn(`stream path not found for session ${sessionId}`);
return res.status(404).json({ error: 'Session stream not found' });
}
// Set up SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'X-Accel-Buffering': 'no', // Disable Nginx buffering
'Content-Encoding': 'identity', // Prevent compression
});
// Force headers to be sent immediately
res.flushHeaders();
// Send initial connection event
res.write(':ok\n\n');
if (res.flush)
res.flush();
// Add client to stream watcher
streamWatcher.addClient(sessionId, streamPath, res);
logger.debug(`SSE stream setup completed in ${Date.now() - startTime}ms`);
// Send heartbeat every 30 seconds to keep connection alive
const heartbeat = setInterval(() => {
res.write(':heartbeat\n\n');
if (res.flush)
res.flush();
}, 30000);
// Track if cleanup has been called to avoid duplicate calls
let cleanedUp = false;
const cleanup = () => {
if (!cleanedUp) {
cleanedUp = true;
logger.log(chalk_1.default.yellow(`SSE client disconnected from session ${sessionId}`));
streamWatcher.removeClient(sessionId, res);
clearInterval(heartbeat);
}
};
// Clean up on disconnect - listen to all possible events
req.on('close', cleanup);
req.on('error', (err) => {
logger.error(`SSE client error for session ${sessionId}:`, err);
cleanup();
});
res.on('close', cleanup);
res.on('finish', cleanup);
});
// Send input to session
router.post('/sessions/:sessionId/input', async (req, res) => {
const sessionId = req.params.sessionId;
const { text, key } = req.body;
// Validate that only one of text or key is provided
if ((text === undefined && key === undefined) || (text !== undefined && key !== undefined)) {
logger.warn(`invalid input request for session ${sessionId}: both or neither text/key provided`);
return res.status(400).json({ error: 'Either text or key must be provided, but not both' });
}
if (text !== undefined && typeof text !== 'string') {
logger.warn(`invalid input request for session ${sessionId}: text is not a string`);
return res.status(400).json({ error: 'Text must be a string' });
}
if (key !== undefined && typeof key !== 'string') {
logger.warn(`invalid input request for session ${sessionId}: key is not a string`);
return res.status(400).json({ error: 'Key must be a string' });
}
try {
// If in HQ mode, check if this is a remote session
if (isHQMode && remoteRegistry) {
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
if (remote) {
// Forward input to remote server
try {
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/input`, {
method: types_js_1.HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${remote.token}`,
},
body: JSON.stringify(req.body),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
return res.json(await response.json());
}
catch (error) {
logger.error(`failed to send input to remote ${remote.name}:`, error);
return res.status(503).json({ error: 'Failed to reach remote server' });
}
}
}
// Local session handling
const session = ptyManager.getSession(sessionId);
if (!session) {
logger.error(`session ${sessionId} not found for input`);
return res.status(404).json({ error: 'Session not found' });
}
if (session.status !== 'running') {
logger.error(`session ${sessionId} is not running (status: ${session.status})`);
return res.status(400).json({ error: 'Session is not running' });
}
const inputData = text !== undefined ? { text } : { key };
logger.debug(`sending input to session ${sessionId}: ${JSON.stringify(inputData)}`);
ptyManager.sendInput(sessionId, inputData);
res.json({ success: true });
}
catch (error) {
logger.error('error sending input:', error);
if (error instanceof index_js_1.PtyError) {
res.status(500).json({ error: 'Failed to send input',