/** * @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js. * * Defines the CodemanApp class (constructor, init, SSE connection, session lifecycle, tabs, * navigation). Domain-specific methods are mixed in from separate modules via Object.assign: * * terminal-ui.js — Terminal setup, rendering pipeline, controls * respawn-ui.js — Respawn banner, countdown timers, presets, run summary * ralph-panel.js — Ralph state panel, fix_plan, plan versioning * settings-ui.js — App settings, visibility, web push, lifecycle log, tunnel/QR, help * panels-ui.js — Subagent panel, agent teams, project insights, file browser, log viewer, * image popups, monitor, token stats, toast, system stats * session-ui.js — Quick start, session options modal, case settings, mobile case picker * ralph-wizard.js — Ralph Loop wizard modal * api-client.js — API helper methods (fetch wrappers) * subagent-windows.js — Floating subagent terminal windows * * ═══ Sections in this file ═══ * * SSE Handler Map — Event-to-method routing table (resolves at runtime via `this`) * CodemanApp Class — Constructor and all state initialization (~80 properties) * Pending Hooks — Hook state machine for tab alerts * Init — App bootstrap, mobile setup, WebGL init * Event Listeners — Keyboard shortcuts, resize, beforeunload * SSE Connection — connectSSE with exponential backoff (1-30s) * Core SSE Event Handlers — Session lifecycle, scheduled runs (~20 handlers) * Connection Status — Online detection, input queuing, state sync * WebSocket Terminal I/O — Low-latency WS bypass for terminal input * Session Tabs — Tab rendering, selection, drag-and-drop reordering * Tab Order & Drag-and-Drop — Persistent ordering with localStorage sync * Session Lifecycle — Select, close, navigate, rename, cleanup * Navigation — goHome * Kill Sessions — Kill active/all sessions * Timer / Tokens — Session timer, token/cost display * Module Init — localStorage migration, app instantiation * * @class CodemanApp * @globals {CodemanApp} app - Singleton instance (also on window.app) * * @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, DEC_SYNC_STRIP_RE) * @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler) * @dependency voice-input.js (VoiceInput, DeepgramProvider) * @dependency notification-manager.js (NotificationManager class) * @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay) * @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js */ // Codeman App - Tab-based Terminal UI // Constants, utilities, and escapeHtml() are in constants.js (loaded before this file) // MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js // DeepgramProvider, VoiceInput are in voice-input.js // ═══════════════════════════════════════════════════════════════ // Global Error & Performance Diagnostics // ═══════════════════════════════════════════════════════════════ // Writes breadcrumbs to localStorage so they survive tab freezes. // After a crash, check: localStorage.getItem('codeman-crash-diag') const _crashDiag = { _entries: [], _maxEntries: 50, // Per-page-load id: the server keys beacons by it, so a reload (fresh id) // archives the previous page's trail instead of overwriting it, and // concurrent clients (desktop + phone) don't clobber each other. _pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8), log(msg) { const entry = `${new Date().toISOString().slice(11,23)} ${msg}`; this._entries.push(entry); if (this._entries.length > this._maxEntries) this._entries.shift(); try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {} } }; // Log previous crash breadcrumbs on startup, and re-beacon them under a // distinct page id — an iOS PWA reload wipes the in-memory trail, and without // this the fresh page's first beacon would be all the server ever sees. try { const prev = localStorage.getItem('codeman-crash-diag'); if (prev) { console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' })); } } catch {} _crashDiag.log('PAGE LOAD'); // Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes. function _crashDiagBeacon() { try { if (_crashDiag._entries.length > 0) { navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId })); } } catch {} } setInterval(() => { try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {} _crashDiagBeacon(); }, 2000); // iOS suspends JS the instant the app is backgrounded — entries logged since // the last 2s tick would sit unsent until (if ever) the page resumes. Flush // immediately on hide so a repro right before an app switch is never lost. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') _crashDiagBeacon(); }); window.addEventListener('error', (e) => { _crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`); console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack); }); window.addEventListener('unhandledrejection', (e) => { _crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`); console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack); }); // Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive" if (typeof PerformanceObserver !== 'undefined') { try { const longTaskObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.duration > 200) { _crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`); console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`); } } }); longTaskObserver.observe({ type: 'longtask', buffered: true }); } catch { /* longtask not supported */ } } // Track WebGL context loss/restore events on all canvases const _origGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, ...args) { const ctx = _origGetContext.call(this, type, ...args); if (type === 'webgl2' || type === 'webgl') { this.addEventListener('webglcontextlost', (e) => { _crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`); console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented); }); this.addEventListener('webglcontextrestored', () => { _crashDiag.log('WEBGL_RESTORED'); console.warn('[CRASH-DIAG] WebGL context restored'); }); } return ctx; }; // ═══════════════════════════════════════════════════════════════ // SSE Handler Map — event-to-method routing table // ═══════════════════════════════════════════════════════════════ // connectSSE() iterates this array to register all listeners in a single loop. // Omitted no-op events (registered by server but unused in UI): // respawn:stepSent, respawn:aiCheckStarted, respawn:aiCheckCompleted, // respawn:aiCheckFailed, respawn:aiCheckCooldown const _SSE_HANDLER_MAP = [ // Core [SSE_EVENTS.INIT, '_onInit'], // Session lifecycle [SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'], [SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'], [SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'], [SSE_EVENTS.SESSION_TERMINAL, '_onSSETerminal'], [SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSSENeedsRefresh'], [SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSSEClearTerminal'], [SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'], [SSE_EVENTS.SESSION_ERROR, '_onSessionError'], [SSE_EVENTS.SESSION_EXIT, '_onSessionExit'], [SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'], [SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'], [SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'], [SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'], [SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'], [SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'], [SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'], [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], [SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], [SSE_EVENTS.SCHEDULED_UPDATED, '_onScheduledUpdated'], [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'], // Scheduled jobs (cron-style scheduler) [SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'], [SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'], [SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'], [SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'], // Respawn [SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'], [SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'], [SSE_EVENTS.RESPAWN_STATE_CHANGED, '_onRespawnStateChanged'], [SSE_EVENTS.RESPAWN_CYCLE_STARTED, '_onRespawnCycleStarted'], [SSE_EVENTS.RESPAWN_BLOCKED, '_onRespawnBlocked'], [SSE_EVENTS.RESPAWN_AUTO_ACCEPT_SENT, '_onRespawnAutoAcceptSent'], [SSE_EVENTS.RESPAWN_DETECTION_UPDATE, '_onRespawnDetectionUpdate'], [SSE_EVENTS.RESPAWN_TIMER_STARTED, '_onRespawnTimerStarted'], [SSE_EVENTS.RESPAWN_TIMER_CANCELLED, '_onRespawnTimerCancelled'], [SSE_EVENTS.RESPAWN_TIMER_COMPLETED, '_onRespawnTimerCompleted'], [SSE_EVENTS.RESPAWN_ERROR, '_onRespawnError'], [SSE_EVENTS.RESPAWN_ACTION_LOG, '_onRespawnActionLog'], // Tasks [SSE_EVENTS.TASK_CREATED, '_onTaskCreated'], [SSE_EVENTS.TASK_COMPLETED, '_onTaskCompleted'], [SSE_EVENTS.TASK_FAILED, '_onTaskFailed'], [SSE_EVENTS.TASK_UPDATED, '_onTaskUpdated'], // Mux (tmux) [SSE_EVENTS.MUX_CREATED, '_onMuxCreated'], [SSE_EVENTS.MUX_KILLED, '_onMuxKilled'], [SSE_EVENTS.MUX_DIED, '_onMuxDied'], [SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'], // Remote auto-reconnect (COD-108) [SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'], [SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'], // Ralph [SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'], [SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'], [SSE_EVENTS.SESSION_RALPH_COMPLETION_DETECTED, '_onRalphCompletionDetected'], [SSE_EVENTS.SESSION_RALPH_STATUS_UPDATE, '_onRalphStatusUpdate'], [SSE_EVENTS.SESSION_CIRCUIT_BREAKER_UPDATE, '_onCircuitBreakerUpdate'], [SSE_EVENTS.SESSION_EXIT_GATE_MET, '_onExitGateMet'], // Bash tools [SSE_EVENTS.SESSION_BASH_TOOL_START, '_onBashToolStart'], [SSE_EVENTS.SESSION_BASH_TOOL_END, '_onBashToolEnd'], [SSE_EVENTS.SESSION_BASH_TOOLS_UPDATE, '_onBashToolsUpdate'], // Hooks (Claude Code hook events) [SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'], [SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'], [SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], // Subagents (Claude Code background agents) [SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'], [SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'], [SSE_EVENTS.SUBAGENT_TOOL_CALL, '_onSubagentToolCall'], [SSE_EVENTS.SUBAGENT_PROGRESS, '_onSubagentProgress'], [SSE_EVENTS.SUBAGENT_MESSAGE, '_onSubagentMessage'], [SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'], [SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'], // Workflow runs (ultracode) [SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'], [SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'], [SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'], // Images [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], [SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'], // Tunnel [SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'], [SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'], [SSE_EVENTS.TUNNEL_PROGRESS, '_onTunnelProgress'], [SSE_EVENTS.TUNNEL_ERROR, '_onTunnelError'], [SSE_EVENTS.TUNNEL_QR_ROTATED, '_onTunnelQrRotated'], [SSE_EVENTS.TUNNEL_QR_REGENERATED, '_onTunnelQrRegenerated'], [SSE_EVENTS.TUNNEL_QR_AUTH_USED, '_onTunnelQrAuthUsed'], // Plan orchestration [SSE_EVENTS.PLAN_SUBAGENT, '_onPlanSubagent'], [SSE_EVENTS.PLAN_PROGRESS, '_onPlanProgress'], [SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'], [SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'], [SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'], // Orchestrator loop [SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'], [SSE_EVENTS.ORCHESTRATOR_PLAN_PROGRESS, '_onOrchestratorPlanProgress'], [SSE_EVENTS.ORCHESTRATOR_PLAN_READY, '_onOrchestratorPlanReady'], [SSE_EVENTS.ORCHESTRATOR_PHASE_STARTED, '_onOrchestratorPhaseStarted'], [SSE_EVENTS.ORCHESTRATOR_PHASE_COMPLETED, '_onOrchestratorPhaseCompleted'], [SSE_EVENTS.ORCHESTRATOR_PHASE_FAILED, '_onOrchestratorPhaseFailed'], [SSE_EVENTS.ORCHESTRATOR_VERIFICATION, '_onOrchestratorVerification'], [SSE_EVENTS.ORCHESTRATOR_TASK_ASSIGNED, '_onOrchestratorTaskAssigned'], [SSE_EVENTS.ORCHESTRATOR_TASK_COMPLETED, '_onOrchestratorTaskCompleted'], [SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'], [SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'], [SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'], // Clipboard [SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'], // Session order (global tab order sync, COD-131) [SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'], // Web tabs (dashboard URLs) [SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'], ]; // ═══════════════════════════════════════════════════════════════ // Session Name Prefix Parser // ═══════════════════════════════════════════════════════════════ // Parses w- or s- prefix from session names. // Returns { prefix, suffix } or null if name does not match the pattern. function parseSessionPrefix(name) { if (!name) return null; const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/); if (!m) return null; const prefix = m[1]; const rest = name.slice(prefix.length); if (rest === "") return { prefix, suffix: "" }; if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) }; return null; } const DEFAULT_SHORTCUTS = [ { id: 'show-shortcuts', group: 'Panels', label: 'Show Shortcuts', bindings: [ { modifiers: ['ctrl'], key: '?', code: 'Slash' }, { modifiers: ['ctrl', 'shift'], key: '?' }, { modifiers: ['alt'], key: '?', code: 'Slash' }, ], action: 'showShortcutOverlay', }, { id: 'close-session', group: 'Session', label: 'Close Session', bindings: [{ modifiers: ['ctrl'], key: 'w' }], action: 'killActiveSession', }, { id: 'next-session', group: 'Session', label: 'Next Session', bindings: [{ modifiers: ['ctrl'], key: 'Tab' }], action: 'nextSession', }, { id: 'clear-terminal', group: 'Terminal', label: 'Clear Terminal', bindings: [{ modifiers: ['ctrl'], key: 'l' }], action: 'clearTerminal', }, { id: 'increase-font', group: 'Terminal', label: 'Increase Font', bindings: [ { modifiers: ['ctrl'], key: '=', code: 'Equal' }, { modifiers: ['ctrl'], key: '+', code: 'Equal' }, ], action: 'increaseFontSize', }, { id: 'decrease-font', group: 'Terminal', label: 'Decrease Font', bindings: [{ modifiers: ['ctrl'], key: '-', code: 'Minus' }], action: 'decreaseFontSize', }, { id: 'voice-input', group: 'Terminal', label: 'Voice Input', bindings: [{ modifiers: ['ctrl', 'shift'], key: 'V' }], action: 'toggleVoiceInput', }, { id: 'restore-terminal-size', group: 'Terminal', label: 'Restore Terminal Size', bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }], action: 'restoreTerminalSize', }, { id: 'move-tab-left', group: 'Tabs', label: 'Move Active Tab Left', bindings: [{ modifiers: ['ctrl', 'shift'], key: '{', code: 'BracketLeft' }], action: 'moveActiveTabLeft', }, { id: 'move-tab-right', group: 'Tabs', label: 'Move Active Tab Right', bindings: [{ modifiers: ['ctrl', 'shift'], key: '}', code: 'BracketRight' }], action: 'moveActiveTabRight', }, { id: 'command-palette', group: 'Session', label: 'Find Open Session', bindings: [ { modifiers: ['ctrl'], key: 'k', code: 'KeyK' }, { modifiers: ['meta'], key: 'k', code: 'KeyK' }, { modifiers: ['alt'], key: 'k', code: 'KeyK' }, ], action: 'openCommandPalette', }, { id: 'previous-next-session', group: 'Session', label: 'Previous / Next Session', displayBindings: ['Alt/Option+[', 'Alt/Option+]'], }, { id: 'switch-tab-n', group: 'Session', label: 'Switch to Tab N', displayBindings: ['Alt/Option+1-9'], }, { id: 'focus-tabs', group: 'Tabs', label: 'Focus Tabs', displayBindings: ['ArrowLeft', 'ArrowRight', 'Home', 'End'], }, { id: 'activate-focused-tab', group: 'Tabs', label: 'Activate Focused Tab', displayBindings: ['Enter', 'Space'], }, { id: 'insert-newline', group: 'Terminal', label: 'Insert Newline', displayBindings: ['Shift+Enter', 'Ctrl+Enter'], }, { id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'], }, ]; // ═══════════════════════════════════════════════════════════════ // CodemanApp Class — constructor and global state // ═══════════════════════════════════════════════════════════════ class CodemanApp { constructor() { this.sessions = new Map(); this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID this.cases = []; this.currentRun = null; this.totalTokens = 0; this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; // Stable per-page client ID — lets the server target this connection // for live filter updates (POST /api/events/subscribe) without forcing // an SSE reconnect on session switches. this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36); // Per-TAB nonce for the WS registry key (COD-137). _loadReliableState() // later replaces _clientId with the browser-wide localStorage identity // (shared by every tab/window of this profile), so the WS upgrade sends // `clientId:nonce` instead — a same-tab reconnect still supersedes its own // socket, but two tabs on one session coexist instead of evicting each // other in a 4010 ping-pong. Input frames keep the bare clientId for seq // dedup. this._wsTabNonce = this._clientId; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; // ── Session detach / undock (beta) ─────────────────────────────────── // A "solo window" is a popped-out browser window showing exactly one // session. Detected from the /session/:id URL path (robust even if a cached // service-worker shell loads), with the server-injected global as a fallback. this.soloSessionId = this._detectSoloSessionId(); this.isSoloWindow = !!this.soloSessionId; this.detachedSessions = new Set(); // dashboard-side: ids currently popped out this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle this.windowChannel = null; // BroadcastChannel for cross-window sync this._redockGrace = new Map(); // id -> timer: deferred redock (debounces popup reloads) this._detachPingPending = null; // Set of ids awaiting a liveness answer this._detachLivenessTimer = null; // periodic reconcile of channel-only detached windows this._detachOrphanStrikes = new Map(); // id -> consecutive unanswered roll-calls (redock at 2) this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47) this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20) this.timerCountdownInterval = null; // Interval for updating countdown display this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session this.ralphStates = new Map(); // Map // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; // Ultracode / Workflow run visualization (master-detail tab) this.workflowRuns = new Map(); // runId -> run summary (LEFT list) this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane) this.activeWorkflowRunId = null; this.activeWorkflowPhaseIndex = null; // Ultracode floating run windows (additional to the dock panel — ultracode-windows.js) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed } this.ultracodeWindowsClosed = new Set(); // runIds the user explicitly dismissed (don't re-pop) this.ultracodeWindowCloseTimers = new Map(); // runId -> auto-close timeout this.ultracodeWindowZIndex = 1000; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - minimized to tab this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide // PERSISTENT parent associations - agentId -> sessionId // This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to. // Once set, never recalculated. Persisted to localStorage and server. this.subagentParentMap = new Map(); // Agent Teams tracking this.teams = new Map(); // Map this.teamTasks = new Map(); // Map this.teammateMap = new Map(); // Map for quick lookup // Teammate tmux pane terminals (Agent Teams feature) this.teammatePanesByName = new Map(); // Map this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel // Plan subagent windows (visible agents during plan generation) this.planSubagents = new Map(); // Map this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE; this.planGenerationStopped = false; // Flag to ignore SSE events after Stop this.planAgentsMinimized = false; // Whether agent windows are minimized to tab // Wizard dragging state this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging } this.wizardDragListeners = null; // { move, up } for cleanup this.wizardPosition = null; // { left, top } - null means centered // Project Insights tracking (active Bash tools with clickable file paths) this.projectInsights = new Map(); // Map this.logViewerWindows = new Map(); // Map this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE; this.projectInsightsPanelVisible = false; // Orchestrator loop state this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats } this.orchestratorPanelVisible = false; this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization // Image popup windows (auto-open for detected screenshots/images) this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; this.attachmentCards = new Map(); // Map this.attachmentCardStack = null; this.attachmentHistoryCounts = new Map(); // Map this.attachmentHistoryItems = []; this.attachmentHistoryDrawerOpen = false; // File browser state (methods in panels-ui.js) this.fileBrowserData = null; this.fileBrowserExpandedDirs = new Set(); this.fileBrowserFilter = ''; this.fileBrowserAllExpanded = false; this.fileBrowserDragListeners = null; this.filePreviewContent = ''; // Toast container cache (methods in panels-ui.js) this._toastContainer = null; // Tunnel indicator state this._tunnelUrl = null; // Tab alert states: Map this.tabAlerts = new Map(); // Pending hooks per session: Map> // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = new Map(); // WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE) this._ws = null; // WebSocket instance for active session this._wsSessionId = null; // Session ID the WS is connected to this._wsReady = false; // True when WS is open and ready for I/O this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load this._bufferLoadSeq = 0; this._bufferLoadOwner = null; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; this.flickerFilterActive = false; this.flickerFilterTimeout = null; // Render debounce timers (managed by _debouncedCall) this._debounceTimers = Object.create(null); // System stats polling this.systemStatsInterval = null; // SSE reconnect timeout (to prevent orphaned timeouts) this.sseReconnectTimeout = null; // SSE event listener cleanup function (to prevent listener accumulation on reconnect) this._sseListenerCleanup = null; // SSE connection status tracking this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.isOnline = navigator.onLine; // Reliable, durable input delivery (replaces the old best-effort queue). // Every input byte is recorded with a stable clientId + a monotonic // per-session seq, persisted to localStorage, and only dropped once the // server ACKs that exact seq — so a half-open socket silently dropping a // frame, a reconnect, or a page reload can never lose a typed prompt. // Exactly-once: the server applies each (clientId, seq) at most once. this._connectionStatus = 'connected'; this._clientId = ''; this._seqCounters = new Map(); // sessionId -> last issued seq this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}] // Last rendered connection-indicator tuple; the hot input path skips DOM // writes when the freshly computed descriptor is identical (COD-136). this._lastIndicatorDescriptor = null; this._postDraining = new Set(); // sessionIds with an in-flight POST drainer this._persistReliableTimer = null; this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog this._loadReliableState(); this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000); // Flush the durable queue synchronously when the page is hidden/closed — // debounced persistence may have a pending write we mustn't lose on reload. window.addEventListener('pagehide', () => this._persistReliableNow()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this._persistReliableNow(); }); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps // Accessibility: Focus trap for modals this.activeFocusTrap = null; // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map for stuck detection // DOM element cache for performance (avoid repeated getElementById calls) this._elemCache = {}; this.init(); } // Cached element getter - avoids repeated DOM queries $(id) { if (!this._elemCache[id]) { this._elemCache[id] = document.getElementById(id); } return this._elemCache[id]; } // Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; } _clearTimer(timerName) { if (this[timerName]) { clearTimeout(this[timerName]); this[timerName] = null; } } // Check if a selectSession generation is stale (a newer tab switch has started). // If stale, cleans up buffer-loading state and returns true. _isStaleSelect(selectGen) { if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(selectGen); this._restoringFlushedState = false; return true; } return false; } // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500 formatTokens(count) { if (count >= 1000000) { const m = count / 1000000; return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`; } else if (count >= 1000) { const k = count / 1000; return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`; } return String(count); } // Estimate cost from tokens using Claude Opus pricing // Input: $15/M tokens, Output: $75/M tokens estimateCost(inputTokens, outputTokens) { const inputCost = (inputTokens / 1000000) * 15; const outputCost = (outputTokens / 1000000) * 75; return inputCost + outputCost; } // ═══════════════════════════════════════════════════════════════ // Pending Hooks State Machine // ═══════════════════════════════════════════════════════════════ // Track pending hook events per session to determine tab alerts. // Action hooks (permission_prompt, elicitation_dialog) take priority over idle_prompt. setPendingHook(sessionId, hookType) { if (!this.pendingHooks.has(sessionId)) { this.pendingHooks.set(sessionId, new Set()); } this.pendingHooks.get(sessionId).add(hookType); this.updateTabAlertFromHooks(sessionId); } clearPendingHooks(sessionId, hookType = null) { const hooks = this.pendingHooks.get(sessionId); if (!hooks) return; if (hookType) { hooks.delete(hookType); } else { hooks.clear(); } if (hooks.size === 0) { this.pendingHooks.delete(sessionId); } this.updateTabAlertFromHooks(sessionId); } updateTabAlertFromHooks(sessionId) { const hooks = this.pendingHooks.get(sessionId); if (!hooks || hooks.size === 0) { this.tabAlerts.delete(sessionId); } else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) { this.tabAlerts.set(sessionId, 'action'); } else if (hooks.has('idle_prompt')) { this.tabAlerts.set(sessionId, 'idle'); } this.renderSessionTabs(); } // ═══════════════════════════════════════════════════════════════ // Init — app bootstrap and mobile setup // ═══════════════════════════════════════════════════════════════ init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); // Detach/undock: open the cross-window sync channel; if this is a solo // (popped-out) window, apply its minimal chrome immediately so the tab // strip never flashes before handleInit selects the target session. this._initWindowChannel(); if (this.isSoloWindow) document.body.classList.add('solo-mode'); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); // Apply keyboard bar mode from settings const _kbSettings = this.loadAppSettingsFromStorage(); if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); this.applyHeaderVisibilitySettings(); this.restorePlanUsageChip(); this.applySkin(); this.applyLocalization(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); // Must run before the first session:created can arrive: markSessionTabEntering() // ignores ids until this sets up its state, which is what keeps the tabs // restored on page load from animating. this.initEntranceAnimations?.(); // Remove mobile-init class now that JS has applied visibility settings. // The inline