From e67f204c05dc0ce17ea177e2b286e20873ea8d77 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Fri, 27 Feb 2026 10:23:08 +1100 Subject: [PATCH 01/39] Add OAuth M2M authentication as alternative to PAT Databricks Apps auto-provisions service principal credentials (DATABRICKS_CLIENT_ID/SECRET). This change adds dual-mode auth: if DATABRICKS_TOKEN is set, use PAT (existing behavior); otherwise, use the SP credentials to generate OAuth Bearer tokens on-the-fly. A background TokenRefresher thread refreshes OAuth tokens every 30 minutes and updates all agent config files (Claude, Gemini, Codex, OpenCode, Databricks CLI) with fresh tokens. Key changes: - utils.py: AuthMode enum, AuthState dataclass, resolve_auth(), TokenRefresher class, _update_all_token_files() - app.py: Wire up resolve_auth() in initialize_app(), remove OAuth credential stripping, inject fresh tokens into sessions - setup_databricks.py, sync_to_workspace.py: Remove PAT-only hardcoding, use SDK auto-detect - setup_claude/codex/gemini/opencode.py: Use resolve_databricks_host_and_token() instead of raw env vars - app.yaml: Make DATABRICKS_TOKEN optional with explanatory comment Co-Authored-By: Claude Opus 4.6 --- app.py | 78 ++++++++++------- app.yaml | 6 +- setup_claude.py | 17 ++-- setup_codex.py | 16 ++-- setup_databricks.py | 19 ++--- setup_gemini.py | 16 ++-- setup_opencode.py | 16 ++-- sync_to_workspace.py | 15 +--- utils.py | 197 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+), 91 deletions(-) diff --git a/app.py b/app.py index 2d7a0da..be9f65a 100644 --- a/app.py +++ b/app.py @@ -11,10 +11,11 @@ import time import copy import logging +import sys from flask import Flask, send_from_directory, request, jsonify, session from collections import deque -from utils import ensure_https +from utils import ensure_https, resolve_auth, AuthMode, TokenRefresher # Session timeout configuration SESSION_TIMEOUT_SECONDS = 60 # No poll for 60s = dead session @@ -66,6 +67,8 @@ def _get_setup_state_snapshot(): # Single-user security: only the token owner can access the terminal app_owner = None +# Token refresher for OAuth M2M mode +token_refresher = None def _run_step(step_id, command): @@ -74,8 +77,6 @@ def _run_step(step_id, command): env = os.environ.copy() if not env.get("HOME") or env["HOME"] == "/": env["HOME"] = "/app/python/source_code" - env.pop("DATABRICKS_CLIENT_ID", None) - env.pop("DATABRICKS_CLIENT_SECRET", None) result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=300) if result.returncode == 0: @@ -95,20 +96,17 @@ def _setup_git_config(): if not home or home == "/": home = "/app/python/source_code" - # Get user identity from Databricks token + # Get user identity from Databricks credentials (PAT or OAuth M2M) user_email = None display_name = None try: from databricks.sdk import WorkspaceClient - db_host = ensure_https(os.environ.get("DATABRICKS_HOST", "")) - db_token = os.environ.get("DATABRICKS_TOKEN") - if db_host and db_token: - w = WorkspaceClient(host=db_host, token=db_token, auth_type="pat") - me = w.current_user.me() - user_email = me.user_name - display_name = me.display_name or user_email.split("@")[0] + w = WorkspaceClient() + me = w.current_user.me() + user_email = me.user_name + display_name = me.display_name or user_email.split("@")[0] except Exception as e: - logger.warning(f"Could not get user identity from token: {e}") + logger.warning(f"Could not get user identity: {e}") # Write ~/.gitconfig directly (more reliable than subprocess git config) gitconfig_path = os.path.join(home, ".gitconfig") @@ -183,11 +181,13 @@ def run_setup(): _run_step("micro", ["bash", "-c", "mkdir -p ~/.local/bin && bash install_micro.sh && mv micro ~/.local/bin/ 2>/dev/null || true"]) - _run_step("claude", ["python", "setup_claude.py"]) - _run_step("codex", ["python", "setup_codex.py"]) - _run_step("opencode", ["python", "setup_opencode.py"]) - _run_step("gemini", ["python", "setup_gemini.py"]) - _run_step("databricks", ["python", "setup_databricks.py"]) + # Use the currently running interpreter instead of assuming `python` exists in PATH. + py = sys.executable or "python" + _run_step("claude", [py, "setup_claude.py"]) + _run_step("codex", [py, "setup_codex.py"]) + _run_step("opencode", [py, "setup_opencode.py"]) + _run_step("gemini", [py, "setup_gemini.py"]) + _run_step("databricks", [py, "setup_databricks.py"]) with setup_lock: any_error = any(s["status"] == "error" for s in setup_state["steps"]) @@ -195,15 +195,21 @@ def run_setup(): setup_state["completed_at"] = time.time() -def get_token_owner(): - """Get the owner email from DATABRICKS_TOKEN at startup.""" +def _get_app_owner(auth): + """Get the owner email for authorization. + + PAT mode: returns user email (existing behavior). + OAuth M2M mode: returns None - Databricks Apps proxy handles access control. + """ + if auth.mode == AuthMode.OAUTH_M2M: + logger.info("OAuth M2M mode: authorization delegated to Databricks Apps proxy") + return None + try: from databricks.sdk import WorkspaceClient - host = ensure_https(os.environ.get("DATABRICKS_HOST", "")) - token = os.environ.get("DATABRICKS_TOKEN") - if not host or not token: + if not auth.host or not auth.token: return None - w = WorkspaceClient(host=host, token=token, auth_type="pat") + w = WorkspaceClient(host=auth.host, token=auth.token, auth_type="pat") return w.current_user.me().user_name except Exception as e: logger.warning(f"Could not determine token owner: {e}") @@ -381,6 +387,10 @@ def create_session(): local_bin = f"{shell_env['HOME']}/.local/bin" shell_env["PATH"] = f"{local_bin}:{shell_env.get('PATH', '')}" + # Inject fresh token from TokenRefresher (OAuth M2M keeps tokens current) + if token_refresher is not None: + shell_env["DATABRICKS_TOKEN"] = token_refresher.current_token + # Start shell in ~/projects/ directory projects_dir = os.path.join(shell_env["HOME"], "projects") os.makedirs(projects_dir, exist_ok=True) @@ -499,15 +509,23 @@ def close_session(): def initialize_app(): - """One-time init: detect owner, start cleanup thread.""" - global app_owner + """One-time init: resolve auth, detect owner, start cleanup + token refresh.""" + global app_owner, token_refresher + + # Resolve authentication (PAT or OAuth M2M) + auth = resolve_auth() + logger.info(f"Auth resolved: mode={auth.mode.value}, host={auth.host}") + + # Set DATABRICKS_TOKEN env var so setup scripts and subprocesses can use it + if auth.token: + os.environ["DATABRICKS_TOKEN"] = auth.token - # Remove OAuth credentials - force PAT auth only - os.environ.pop("DATABRICKS_CLIENT_ID", None) - os.environ.pop("DATABRICKS_CLIENT_SECRET", None) + # Start token refresher (only active in OAuth M2M mode) + token_refresher = TokenRefresher(auth) + token_refresher.start() - # Determine app owner from DATABRICKS_TOKEN - app_owner = get_token_owner() + # Determine app owner + app_owner = _get_app_owner(auth) if app_owner: logger.info(f"App owner (from token): {app_owner}") else: diff --git a/app.yaml b/app.yaml index 7e634a4..0bed46b 100644 --- a/app.yaml +++ b/app.yaml @@ -4,8 +4,10 @@ command: env: - name: HOME value: /app/python/source_code - - name: DATABRICKS_TOKEN - valueFrom: DATABRICKS_TOKEN + # DATABRICKS_TOKEN: set this secret for PAT auth. If not set, the app uses + # auto-provisioned OAuth M2M credentials (DATABRICKS_CLIENT_ID/SECRET). + # - name: DATABRICKS_TOKEN + # valueFrom: DATABRICKS_TOKEN - name: ANTHROPIC_MODEL value: databricks-claude-opus-4-6 - name: GEMINI_MODEL diff --git a/setup_claude.py b/setup_claude.py index 128ef37..7c98bc6 100644 --- a/setup_claude.py +++ b/setup_claude.py @@ -3,7 +3,7 @@ import subprocess from pathlib import Path -from utils import ensure_https +from utils import ensure_https, resolve_databricks_host_and_token # Set HOME if not properly set if not os.environ.get("HOME") or os.environ["HOME"] == "/": @@ -18,20 +18,20 @@ # 1. Write settings.json for Databricks model serving # Use DATABRICKS_GATEWAY_HOST if available (new AI Gateway), otherwise fall back to DATABRICKS_HOST gateway_host = ensure_https(os.environ.get("DATABRICKS_GATEWAY_HOST", "").rstrip("/")) -databricks_host = ensure_https(os.environ.get("DATABRICKS_HOST", "").rstrip("/")) +databricks_host, auth_token = resolve_databricks_host_and_token() -gateway_token = os.environ.get("DATABRICKS_TOKEN", "") if gateway_host else "" -if gateway_host and not gateway_token: - print("Warning: DATABRICKS_GATEWAY_HOST set but DATABRICKS_TOKEN missing, falling back to DATABRICKS_HOST") +if gateway_host and not auth_token: + print("Warning: DATABRICKS_GATEWAY_HOST set but token unavailable, falling back to DATABRICKS_HOST") gateway_host = "" if gateway_host: anthropic_base_url = f"{gateway_host}/anthropic" - auth_token = gateway_token print(f"Using Databricks AI Gateway: {gateway_host}") else: + if not databricks_host or not auth_token: + print("Error: could not resolve Databricks host/token for Claude setup") + raise SystemExit(1) anthropic_base_url = f"{databricks_host}/serving-endpoints/anthropic" - auth_token = os.environ["DATABRICKS_TOKEN"] print(f"Using Databricks Host: {databricks_host}") settings = { @@ -82,7 +82,8 @@ if result.returncode == 0: print("Claude Code CLI installed successfully") else: - print(f"CLI install warning: {result.stderr}") + print(f"CLI install failed: {result.stderr}") + raise SystemExit(1) else: print(f"Claude Code CLI already installed at {claude_bin}") diff --git a/setup_codex.py b/setup_codex.py index ac3e0f2..ced756e 100644 --- a/setup_codex.py +++ b/setup_codex.py @@ -12,7 +12,7 @@ import subprocess from pathlib import Path -from utils import adapt_instructions_file, ensure_https +from utils import adapt_instructions_file, ensure_https, resolve_databricks_host_and_token # Set HOME if not properly set if not os.environ.get("HOME") or os.environ["HOME"] == "/": @@ -20,22 +20,21 @@ home = Path(os.environ["HOME"]) -host = os.environ.get("DATABRICKS_HOST", "") -token = os.environ.get("DATABRICKS_TOKEN", "") +host, token = resolve_databricks_host_and_token() codex_model = os.environ.get("CODEX_MODEL", "databricks-gpt-5-2") if not host or not token: - print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping Codex CLI config") - exit(0) + print("Error: DATABRICKS_HOST or auth token not available, cannot configure Codex CLI") + raise SystemExit(1) # Strip trailing slash and ensure https:// prefix host = ensure_https(host.rstrip("/")) # Use DATABRICKS_GATEWAY_HOST if available (new AI Gateway), otherwise fall back to DATABRICKS_HOST gateway_host = ensure_https(os.environ.get("DATABRICKS_GATEWAY_HOST", "").rstrip("/")) -gateway_token = os.environ.get("DATABRICKS_TOKEN", "") if gateway_host else "" +gateway_token = token if gateway_host else "" if gateway_host and not gateway_token: - print("Warning: DATABRICKS_GATEWAY_HOST set but DATABRICKS_TOKEN missing, falling back to DATABRICKS_HOST") + print("Warning: DATABRICKS_GATEWAY_HOST set but token unavailable, falling back to DATABRICKS_HOST") gateway_host = "" if gateway_host: @@ -65,7 +64,8 @@ if result.returncode == 0: print(f"Codex CLI installed to {codex_bin}") else: - print(f"Codex CLI install warning: {result.stderr}") + print(f"Codex CLI install failed: {result.stderr}") + raise SystemExit(1) else: print(f"Codex CLI already installed at {codex_bin}") diff --git a/setup_databricks.py b/setup_databricks.py index 85f21f4..27f62f0 100644 --- a/setup_databricks.py +++ b/setup_databricks.py @@ -4,7 +4,7 @@ import subprocess from pathlib import Path -from utils import ensure_https +from utils import resolve_databricks_host_and_token # Set HOME if not properly set if not os.environ.get("HOME") or os.environ["HOME"] == "/": @@ -12,15 +12,12 @@ home = Path(os.environ["HOME"]) -# Get credentials from environment -host = os.environ.get("DATABRICKS_HOST") -token = os.environ.get("DATABRICKS_TOKEN") +# Get credentials from environment or SDK auto-auth fallback +host, token = resolve_databricks_host_and_token() if not host or not token: - print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping CLI config") - exit(0) - -host = ensure_https(host) + print("Error: DATABRICKS_HOST or auth token not available, cannot configure Databricks CLI") + raise SystemExit(1) # Create ~/.databrickscfg with DEFAULT profile using PAT auth databrickscfg = home / ".databrickscfg" @@ -38,12 +35,6 @@ ["databricks", "current-user", "me", "--output", "json"], capture_output=True, text=True, - env={ - **os.environ, - # Remove OAuth vars to force PAT auth - "DATABRICKS_CLIENT_ID": "", - "DATABRICKS_CLIENT_SECRET": "" - } ) if result.returncode == 0: diff --git a/setup_gemini.py b/setup_gemini.py index 5dc3412..425416e 100644 --- a/setup_gemini.py +++ b/setup_gemini.py @@ -16,7 +16,7 @@ import subprocess from pathlib import Path -from utils import adapt_instructions_file, ensure_https +from utils import adapt_instructions_file, ensure_https, resolve_databricks_host_and_token # Set HOME if not properly set if not os.environ.get("HOME") or os.environ["HOME"] == "/": @@ -24,22 +24,21 @@ home = Path(os.environ["HOME"]) -host = os.environ.get("DATABRICKS_HOST", "") -token = os.environ.get("DATABRICKS_TOKEN", "") +host, token = resolve_databricks_host_and_token() gemini_model = os.environ.get("GEMINI_MODEL", "databricks-gemini-3-1-pro") if not host or not token: - print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping Gemini CLI config") - exit(0) + print("Error: DATABRICKS_HOST or auth token not available, cannot configure Gemini CLI") + raise SystemExit(1) # Strip trailing slash and ensure https:// prefix host = ensure_https(host.rstrip("/")) # Use DATABRICKS_GATEWAY_HOST if available (new AI Gateway), otherwise fall back to DATABRICKS_HOST gateway_host = ensure_https(os.environ.get("DATABRICKS_GATEWAY_HOST", "").rstrip("/")) -gateway_token = os.environ.get("DATABRICKS_TOKEN", "") if gateway_host else "" +gateway_token = token if gateway_host else "" if gateway_host and not gateway_token: - print("Warning: DATABRICKS_GATEWAY_HOST set but DATABRICKS_TOKEN missing, falling back to DATABRICKS_HOST") + print("Warning: DATABRICKS_GATEWAY_HOST set but token unavailable, falling back to DATABRICKS_HOST") gateway_host = "" if gateway_host: @@ -68,7 +67,8 @@ if result.returncode == 0: print(f"Gemini CLI installed to {gemini_bin}") else: - print(f"Gemini CLI install warning: {result.stderr}") + print(f"Gemini CLI install failed: {result.stderr}") + raise SystemExit(1) else: print(f"Gemini CLI already installed at {gemini_bin}") diff --git a/setup_opencode.py b/setup_opencode.py index 5e46078..9db57ee 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path -from utils import ensure_https +from utils import ensure_https, resolve_databricks_host_and_token # Set HOME if not properly set if not os.environ.get("HOME") or os.environ["HOME"] == "/": @@ -13,22 +13,21 @@ home = Path(os.environ["HOME"]) -host = os.environ.get("DATABRICKS_HOST", "") -token = os.environ.get("DATABRICKS_TOKEN", "") +host, token = resolve_databricks_host_and_token() anthropic_model = os.environ.get("ANTHROPIC_MODEL", "databricks-claude-sonnet-4-6") if not host or not token: - print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping OpenCode config") - exit(0) + print("Error: DATABRICKS_HOST or auth token not available, cannot configure OpenCode") + raise SystemExit(1) # Strip trailing slash and ensure https:// prefix host = ensure_https(host.rstrip("/")) # Use DATABRICKS_GATEWAY_HOST if available (new AI Gateway), otherwise fall back to current gateway (DATABRICKS_HOST) gateway_host = ensure_https(os.environ.get("DATABRICKS_GATEWAY_HOST", "").rstrip("/")) -gateway_token = os.environ.get("DATABRICKS_TOKEN", "") if gateway_host else "" +gateway_token = token if gateway_host else "" if gateway_host and not gateway_token: - print("Warning: DATABRICKS_GATEWAY_HOST set but DATABRICKS_TOKEN missing, falling back to DATABRICKS_HOST") + print("Warning: DATABRICKS_GATEWAY_HOST set but token unavailable, falling back to DATABRICKS_HOST") gateway_host = "" if gateway_host: @@ -53,7 +52,8 @@ if result.returncode == 0: print(f"OpenCode CLI installed to {opencode_bin}") else: - print(f"OpenCode install warning: {result.stderr}") + print(f"OpenCode install failed: {result.stderr}") + raise SystemExit(1) else: print(f"OpenCode CLI already installed at {opencode_bin}") diff --git a/sync_to_workspace.py b/sync_to_workspace.py index 1d1a939..94d1933 100644 --- a/sync_to_workspace.py +++ b/sync_to_workspace.py @@ -17,13 +17,8 @@ def get_user_email(): - """Get current user's email from Databricks token.""" - # Force PAT auth, ignore OAuth credentials - w = WorkspaceClient( - host=os.environ.get("DATABRICKS_HOST"), - token=os.environ.get("DATABRICKS_TOKEN"), - auth_type="pat" - ) + """Get current user's email from Databricks credentials.""" + w = WorkspaceClient() return w.current_user.me().user_name @@ -42,16 +37,10 @@ def sync_project(project_path: Path): user_email = get_user_email() workspace_dest = f"/Workspace/Users/{user_email}/projects/{project_path.name}" - # Create env with only PAT auth (remove OAuth vars) - sync_env = os.environ.copy() - sync_env.pop("DATABRICKS_CLIENT_ID", None) - sync_env.pop("DATABRICKS_CLIENT_SECRET", None) - result = subprocess.run( ["databricks", "sync", str(project_path), workspace_dest, "--watch=false"], capture_output=True, text=True, - env=sync_env ) if result.returncode == 0: diff --git a/utils.py b/utils.py index dcc36d5..3023dfb 100644 --- a/utils.py +++ b/utils.py @@ -1,7 +1,16 @@ """Shared utilities for Databricks App setup scripts.""" +import enum +import logging +import os import re +import threading +import time +from dataclasses import dataclass from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) def adapt_instructions_file( @@ -57,3 +66,191 @@ def ensure_https(url: str) -> str: if not url.startswith(("http://", "https://")): return f"https://{url}" return url + + +class AuthMode(enum.Enum): + """How the app authenticates with Databricks.""" + PAT = "pat" + OAUTH_M2M = "oauth_m2m" + + +@dataclass +class AuthState: + """Resolved authentication state.""" + mode: AuthMode + host: str + token: str + # Only populated for OAUTH_M2M + client_id: Optional[str] = None + client_secret: Optional[str] = None + + +def resolve_auth() -> AuthState: + """Resolve Databricks authentication - PAT first, OAuth M2M fallback. + + Priority: + 1) DATABRICKS_TOKEN set -> PAT mode (existing behavior) + 2) DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET set -> OAuth M2M + 3) SDK auto-detect (WorkspaceClient.config.authenticate()) + + Returns: + AuthState with mode, host, and token. + """ + host = ensure_https(os.environ.get("DATABRICKS_HOST", "").strip()) + token = os.environ.get("DATABRICKS_TOKEN", "").strip() + + # 1. PAT mode - explicit token + if host and token: + logger.info("Auth mode: PAT (explicit DATABRICKS_TOKEN)") + return AuthState(mode=AuthMode.PAT, host=host, token=token) + + # 2. OAuth M2M - auto-provisioned SP credentials + client_id = os.environ.get("DATABRICKS_CLIENT_ID", "").strip() + client_secret = os.environ.get("DATABRICKS_CLIENT_SECRET", "").strip() + + if host and client_id and client_secret: + logger.info("Auth mode: OAuth M2M (service principal credentials)") + oauth_token = _generate_oauth_token(host, client_id, client_secret) + return AuthState( + mode=AuthMode.OAUTH_M2M, + host=host, + token=oauth_token, + client_id=client_id, + client_secret=client_secret, + ) + + # 3. SDK auto-detect fallback + try: + from databricks.sdk import WorkspaceClient + + client = WorkspaceClient() + if not host: + host = ensure_https((client.config.host or "").strip()) + + auth_headers = client.config.authenticate() or {} + authorization = auth_headers.get("Authorization", "") + if authorization.startswith("Bearer "): + token = authorization.replace("Bearer ", "", 1).strip() + elif not token: + token = (getattr(client.config, "token", "") or "").strip() + + if host and token: + logger.info("Auth mode: SDK auto-detect") + return AuthState(mode=AuthMode.PAT, host=host, token=token) + except Exception as e: + logger.warning(f"SDK auto-detect failed: {e}") + + # Return whatever we have (may be incomplete) + logger.warning("Auth: could not fully resolve credentials") + return AuthState(mode=AuthMode.PAT, host=host, token=token) + + +def _generate_oauth_token(host: str, client_id: str, client_secret: str) -> str: + """Generate an OAuth Bearer token using SP credentials. + + Uses WorkspaceClient.config.authenticate() which handles the OAuth token + exchange with Databricks' OIDC endpoint. + """ + from databricks.sdk import WorkspaceClient + + client = WorkspaceClient( + host=host, + client_id=client_id, + client_secret=client_secret, + ) + auth_headers = client.config.authenticate() + authorization = auth_headers.get("Authorization", "") + if authorization.startswith("Bearer "): + return authorization.replace("Bearer ", "", 1).strip() + raise RuntimeError("OAuth M2M token exchange did not return a Bearer token") + + +class TokenRefresher: + """Background thread that refreshes OAuth tokens and updates config files. + + Only active in OAUTH_M2M mode. Refreshes every `interval` seconds and + updates all agent config files with the new token. + """ + + def __init__(self, auth: AuthState, interval: int = 1800): + self._auth = auth + self._interval = interval + self._lock = threading.Lock() + self._current_token = auth.token + self._thread: Optional[threading.Thread] = None + + @property + def current_token(self) -> str: + with self._lock: + return self._current_token + + def start(self): + if self._auth.mode != AuthMode.OAUTH_M2M: + logger.info("TokenRefresher: PAT mode, no refresh needed") + return + self._thread = threading.Thread( + target=self._run, daemon=True, name="token-refresher" + ) + self._thread.start() + logger.info(f"TokenRefresher: started (interval={self._interval}s)") + + def _run(self): + while True: + time.sleep(self._interval) + try: + old_token = self.current_token + new_token = _generate_oauth_token( + self._auth.host, + self._auth.client_id, + self._auth.client_secret, + ) + with self._lock: + self._current_token = new_token + + # Update DATABRICKS_TOKEN env var so new subprocesses pick it up + os.environ["DATABRICKS_TOKEN"] = new_token + + # Update all config files that contain the old token + _update_all_token_files(old_token, new_token) + logger.info("TokenRefresher: token refreshed and config files updated") + except Exception as e: + logger.error(f"TokenRefresher: refresh failed: {e}") + + +def _update_all_token_files(old_token: str, new_token: str): + """Replace old_token with new_token in all agent config files.""" + if old_token == new_token or not old_token or not new_token: + return + + home = Path(os.environ.get("HOME", "/app/python/source_code")) + + config_files = [ + home / ".claude" / "settings.json", # ANTHROPIC_AUTH_TOKEN + home / ".gemini" / ".env", # GEMINI_API_KEY + home / ".codex" / ".env", # OPENAI_API_KEY + home / ".local" / "share" / "opencode" / "auth.json", # api_key + home / ".databrickscfg", # token + ] + + for path in config_files: + if not path.exists(): + continue + try: + content = path.read_text() + if old_token in content: + path.write_text(content.replace(old_token, new_token)) + logger.debug(f"TokenRefresher: updated {path}") + except Exception as e: + logger.warning(f"TokenRefresher: failed to update {path}: {e}") + + +def resolve_databricks_host_and_token() -> tuple[str, str]: + """Resolve Databricks host + auth token for setup scripts. + + Backward-compatible wrapper around resolve_auth(). + + Returns: + (host, token) where each value may be an empty string if unresolved. + """ + auth = resolve_auth() + return auth.host, auth.token From c94d488a0a5edd951fa365bfc3ccbfd3f2861903 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Thu, 5 Mar 2026 22:53:49 +1100 Subject: [PATCH 02/39] feat: Add multi-terminal support and git credential helper - Multi-terminal UI with 4 layouts (single, hsplit, vsplit, quad) - Toolbar with layout buttons, pane indicators, focus management - Batch /api/output-batch endpoint for efficient multi-session polling - Git credential helper (~/.local/bin/git-credential-databricks) for HTTPS git auth using DATABRICKS_TOKEN - Ctrl+Shift+N to cycle focus, debounced resize, close/add pane buttons - 46 tests covering backend endpoints, credential helper, and frontend Co-Authored-By: Claude Opus 4.6 --- app.py | 64 +++ docs/prd/multi-terminal-git-auth.md | 106 +++++ static/index.html | 633 ++++++++++++++++++++++++---- tests/__init__.py | 0 tests/conftest.py | 50 +++ tests/test_batch_output.py | 126 ++++++ tests/test_frontend_structure.py | 276 ++++++++++++ tests/test_git_credential_helper.py | 170 ++++++++ 8 files changed, 1333 insertions(+), 92 deletions(-) create mode 100644 docs/prd/multi-terminal-git-auth.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_batch_output.py create mode 100644 tests/test_frontend_structure.py create mode 100644 tests/test_git_credential_helper.py diff --git a/app.py b/app.py index be9f65a..1c5e551 100644 --- a/app.py +++ b/app.py @@ -113,6 +113,35 @@ def _setup_git_config(): hooks_dir = os.path.join(home, ".githooks") os.makedirs(hooks_dir, exist_ok=True) + # Write git credential helper script + local_bin = os.path.join(home, ".local", "bin") + os.makedirs(local_bin, exist_ok=True) + credential_helper_path = os.path.join(local_bin, "git-credential-databricks") + with open(credential_helper_path, "w") as f: + f.write('#!/bin/bash\n') + f.write('# Git credential helper that uses DATABRICKS_TOKEN for HTTPS auth.\n') + f.write('# Implements the git credential helper protocol.\n') + f.write('\n') + f.write('# Only respond to "get" action; silently ignore store/erase.\n') + f.write('if [ "$1" != "get" ]; then\n') + f.write(' exit 0\n') + f.write('fi\n') + f.write('\n') + f.write('# Read stdin (protocol, host, etc.) -- required by protocol but we\n') + f.write('# serve credentials for all hosts.\n') + f.write('while IFS= read -r line; do\n') + f.write(' [ -z "$line" ] && break\n') + f.write('done\n') + f.write('\n') + f.write('# If DATABRICKS_TOKEN is not set, exit non-zero so git tries other helpers.\n') + f.write('if [ -z "$DATABRICKS_TOKEN" ]; then\n') + f.write(' exit 1\n') + f.write('fi\n') + f.write('\n') + f.write('printf "username=token\\npassword=%s\\n" "$DATABRICKS_TOKEN"\n') + os.chmod(credential_helper_path, 0o755) + logger.info(f"Git credential helper written to {credential_helper_path}") + lines = [] if user_email and display_name: lines.append("[user]") @@ -120,6 +149,8 @@ def _setup_git_config(): lines.append(f"\tname = {display_name}") lines.append("[core]") lines.append(f"\thooksPath = {hooks_dir}") + lines.append("[credential]") + lines.append(f"\thelper = {credential_helper_path}") with open(gitconfig_path, "w") as f: f.write("\n".join(lines) + "\n") @@ -465,6 +496,39 @@ def get_output(): return jsonify({"output": output, "exited": exited}) +@app.route("/api/output-batch", methods=["POST"]) +def get_output_batch(): + """Get output from multiple terminal sessions in one request. + + Accepts: {"session_ids": ["id1", "id2", ...]} + Returns: {"outputs": {"id1": {"output": "...", "exited": false}, ...}} + + Unknown session_ids are silently skipped (not an error). + """ + data = request.json or {} + session_ids = data.get("session_ids") + + if session_ids is None: + return jsonify({"error": "session_ids required"}), 400 + + outputs = {} + now = time.time() + + with sessions_lock: + for sid in session_ids: + if sid not in sessions: + continue + session = sessions[sid] + session["last_poll_time"] = now + buffer = session["output_buffer"] + output = "".join(buffer) + buffer.clear() + exited = session.get("exited", False) + outputs[sid] = {"output": output, "exited": exited} + + return jsonify({"outputs": outputs}) + + @app.route("/api/resize", methods=["POST"]) def resize_terminal(): """Resize the terminal.""" diff --git a/docs/prd/multi-terminal-git-auth.md b/docs/prd/multi-terminal-git-auth.md new file mode 100644 index 0000000..d437bcd --- /dev/null +++ b/docs/prd/multi-terminal-git-auth.md @@ -0,0 +1,106 @@ +# PRD: Multi-Terminal Support & Git Authentication + +**Status:** COMPLETE +**Author:** Claude Code +**Date:** 2025-03-05 + +--- + +## Problem Statement + +The browser-based terminal app currently supports only a single full-screen terminal. Users running AI coding agents (Claude Code, Gemini CLI, etc.) frequently need multiple terminals simultaneously -- one for the agent, one for testing, one for git operations. Switching between tasks requires closing and reopening sessions. Additionally, git credential helpers are not configured, so HTTPS git operations against GitHub/GitLab fail when users try to clone private repos or push changes. + +## Goals + +1. Enable multiple terminal panes visible simultaneously with predefined layouts +2. Provide a toolbar for layout switching, pane management, and focus control +3. Optimize polling performance with a batch output endpoint +4. Configure git credential helpers so Databricks token-based git operations work seamlessly + +## Non-Goals + +- WebSocket support (Databricks Apps proxy limitation) +- Drag-and-drop pane resizing (keep it simple with predefined layouts) +- Saving/restoring terminal sessions across page reloads +- External JS framework dependencies +- Modifying the loading screen (static/loading.html) + +--- + +## Acceptance Criteria + +### Multi-Terminal UI + +**AC-1: Layout System** +The frontend must support four predefined layouts: "single" (1 terminal, full screen), "hsplit" (2 terminals side-by-side), "vsplit" (2 terminals stacked), and "quad" (4 terminals in a 2x2 grid). Each layout allocates equal space to its panes. + +**AC-2: Toolbar** +A toolbar at the top of the page displays: layout toggle buttons (icons or labels for single/hsplit/vsplit/quad), indicators showing which panes are active, and a visual indicator of which pane has focus. The toolbar must use the existing dark theme (#1e1e1e background). + +**AC-3: Pane Lifecycle** +Each pane gets its own independent PTY session via POST /api/session. Sessions are created when a pane is added and closed (via POST /api/session/close) when a pane is removed. Users can close individual panes via a close button on each pane header. Closing a pane in a layout that requires fewer panes does not force a layout change -- the slot becomes empty and shows a "+" button to reopen. + +**AC-4: Independent Resize** +Each pane's xterm.js instance must report its own correct dimensions. When the window resizes or the layout changes, each pane calls fitAddon.fit() and sends its dimensions via POST /api/resize. Resize events must be debounced (at least 150ms). + +**AC-5: Focus Management** +Clicking a pane gives it focus (visually indicated by a highlighted border). The keyboard shortcut Ctrl+Shift+N cycles focus to the next active pane. The focused pane receives all keyboard input. + +**AC-6: Close Pane** +Each pane has a close button (X) in its header bar. Closing a pane sends POST /api/session/close and removes the terminal from the UI. The pane slot shows a "+" button to create a new session in that slot. + +### Performance + +**AC-7: Batch Output Endpoint** +A new endpoint POST /api/output-batch accepts `{"session_ids": ["id1", "id2", ...]}` and returns `{"outputs": {"id1": {"output": "...", "exited": false}, "id2": {...}}}`. The frontend uses this single endpoint instead of individual /api/output calls to reduce HTTP overhead. The existing /api/output endpoint remains for backward compatibility. + +**AC-8: Polling Efficiency** +The frontend uses a single setInterval (100ms) that calls /api/output-batch with all active session IDs. This replaces per-terminal polling intervals. If no sessions are active, polling pauses. + +### Git Authentication + +**AC-9: Git Credential Helper** +During setup (in setup_databricks.py or app.py's _setup_git_config), a git credential helper script is written to ~/.local/bin/git-credential-databricks. It reads DATABRICKS_TOKEN from the environment and returns it as the password for HTTPS git operations. The ~/.gitconfig is updated to include `[credential] helper = /path/to/git-credential-databricks`. This enables `git clone https://...`, `git push`, etc. to authenticate using the Databricks token for Databricks-hosted repos (Repos API). + +**AC-10: Credential Helper Protocol** +The git credential helper must implement the git credential helper protocol: when invoked with "get" as an argument, it reads key=value pairs from stdin (including "host" and "protocol") and writes `username=token\npassword=\n` to stdout. For any other action (store, erase), it exits silently. + +--- + +## Technical Design + +### Frontend (static/index.html) + +- Replace the single `#terminal` div with a `#toolbar` and `#pane-container` +- TerminalPane class: manages one xterm.js Terminal + FitAddon + session lifecycle +- LayoutManager class: manages pane creation/destruction, CSS grid layout switching +- Single poll loop calls /api/output-batch with all active session IDs +- Debounced resize handler updates all panes + +### Backend (app.py) + +- New route: POST /api/output-batch +- Acquires sessions_lock once, reads all requested buffers, returns combined response + +### Git Auth (setup_databricks.py or _setup_git_config in app.py) + +- Write git-credential-databricks shell script to ~/.local/bin/ +- Append credential helper config to ~/.gitconfig +- The credential helper reads DATABRICKS_TOKEN from env at runtime (so token refresh works) + +### Files Changed + +| File | Change | +|------|--------| +| static/index.html | Complete rewrite: toolbar, layout manager, multi-pane support, batch polling | +| app.py | Add /api/output-batch endpoint | +| app.py (_setup_git_config) | Add git credential helper setup | + +--- + +## Resolved Questions + +1. **Last pane behavior:** Closing the last pane auto-creates a new terminal (always at least one terminal open). +2. **Credential helper scope:** The credential helper works for ALL HTTPS git URLs (general helper, not scoped to Databricks only). + +--- diff --git a/static/index.html b/static/index.html index 4bac556..92bb78e 100644 --- a/static/index.html +++ b/static/index.html @@ -4,138 +4,587 @@ Terminal +
+ Layout: + + + + +
+ Ctrl+Shift+N: cycle focus +
+
Loading...
-
@@ -263,21 +265,25 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: this.sessionId, input: input }) - }); + }).catch(err => console.warn('Input send failed:', err)); }); } async sendResize() { if (!this.sessionId || !this.term) return; - await fetch('/api/resize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: this.sessionId, - cols: this.term.cols, - rows: this.term.rows - }) - }); + try { + await fetch('/api/resize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: this.sessionId, + cols: this.term.cols, + rows: this.term.rows + }) + }); + } catch (err) { + console.warn('Resize failed:', err); + } } fit() { diff --git a/static/loading.html b/static/loading.html index 523ebce..741c8d0 100644 --- a/static/loading.html +++ b/static/loading.html @@ -218,9 +218,30 @@

coding agents on databricks

timeStr = formatDuration(step.completed_at - step.started_at); } - div.innerHTML = '' + icon + '' + step.label + '' + - (timeStr ? '' + timeStr + '' : '') + - (step.error ? '' + step.error.substring(0, 80) + '' : ''); + const iconSpan = document.createElement('span'); + iconSpan.className = 'icon'; + iconSpan.textContent = icon; + div.appendChild(iconSpan); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'label'; + labelSpan.textContent = step.label; + div.appendChild(labelSpan); + + if (timeStr) { + const timeSpan = document.createElement('span'); + timeSpan.className = 'time'; + timeSpan.textContent = timeStr; + div.appendChild(timeSpan); + } + + if (step.error) { + const errorSpan = document.createElement('span'); + errorSpan.className = 'time'; + errorSpan.style.color = '#f85149'; + errorSpan.textContent = step.error.substring(0, 80); + div.appendChild(errorSpan); + } stepsContainer.appendChild(div); }); diff --git a/utils.py b/utils.py index 3023dfb..d613663 100644 --- a/utils.py +++ b/utils.py @@ -20,44 +20,46 @@ def adapt_instructions_file( cli_name: str, ) -> bool: """Read a CLAUDE.md file and adapt it for another CLI's instructions format. - + Reads the source instructions file (typically CLAUDE.md), replaces the first header line with a CLI-specific header, and writes to the target location. - + Args: source_path: Path to the source instructions file (e.g., CLAUDE.md) target_path: Path to write the adapted instructions file new_header: The new header line (e.g., "# Codex Agent Instructions") cli_name: Name of the CLI for logging (e.g., "Codex", "Gemini") - + Returns: True if successful, False if source file not found """ if not source_path.exists(): - print(f"Warning: {source_path} not found, skipping {cli_name} instructions") + logger.warning(f"{source_path} not found, skipping {cli_name} instructions") return False - + content = source_path.read_text() - + # Replace the first markdown header (# ...) with the new header # This handles "# Claude Code on Databricks" -> "# Codex Agent Instructions" - adapted_content = re.sub(r"^#\s+.*$", new_header, content, count=1, flags=re.MULTILINE) - + adapted_content = re.sub( + r"^#\s+.*$", new_header, content, count=1, flags=re.MULTILINE + ) + target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_text(adapted_content) - print(f"{cli_name} instructions configured: {target_path}") + logger.info(f"{cli_name} instructions configured: {target_path}") return True def ensure_https(url: str) -> str: """Ensure a URL has the https:// prefix. - + Databricks Apps may inject DATABRICKS_HOST without the protocol prefix, which causes URL parsing errors downstream. - + Args: url: A URL that may or may not have a protocol prefix - + Returns: The URL with https:// prefix (or unchanged if already has http(s)://) """ @@ -70,6 +72,7 @@ def ensure_https(url: str) -> str: class AuthMode(enum.Enum): """How the app authenticates with Databricks.""" + PAT = "pat" OAUTH_M2M = "oauth_m2m" @@ -77,6 +80,7 @@ class AuthMode(enum.Enum): @dataclass class AuthState: """Resolved authentication state.""" + mode: AuthMode host: str token: str @@ -225,11 +229,11 @@ def _update_all_token_files(old_token: str, new_token: str): home = Path(os.environ.get("HOME", "/app/python/source_code")) config_files = [ - home / ".claude" / "settings.json", # ANTHROPIC_AUTH_TOKEN - home / ".gemini" / ".env", # GEMINI_API_KEY - home / ".codex" / ".env", # OPENAI_API_KEY + home / ".claude" / "settings.json", # ANTHROPIC_AUTH_TOKEN + home / ".gemini" / ".env", # GEMINI_API_KEY + home / ".codex" / ".env", # OPENAI_API_KEY home / ".local" / "share" / "opencode" / "auth.json", # api_key - home / ".databrickscfg", # token + home / ".databrickscfg", # token ] for path in config_files: @@ -239,6 +243,7 @@ def _update_all_token_files(old_token: str, new_token: str): content = path.read_text() if old_token in content: path.write_text(content.replace(old_token, new_token)) + path.chmod(0o600) logger.debug(f"TokenRefresher: updated {path}") except Exception as e: logger.warning(f"TokenRefresher: failed to update {path}: {e}") From 1f1f9c2984fd7a39782d1cc01013c4e692ea80e1 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sat, 7 Mar 2026 22:11:52 +1100 Subject: [PATCH 20/39] fix: Resolve ruff lint and format errors for CI Remove unused imports (os, pytest, tempfile, textwrap), fix f-strings without placeholders, remove unused variable, auto-format. Add ruff.toml to exclude .claude/ vendor skills from linting. Co-Authored-By: Claude Opus 4.6 --- ruff.toml | 1 + sync_to_workspace.py | 6 +- tests/conftest.py | 1 + tests/test_batch_output.py | 11 +-- tests/test_frontend_structure.py | 112 +++++++++++++++------------- tests/test_git_credential_helper.py | 26 +++++-- 6 files changed, 86 insertions(+), 71 deletions(-) create mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..bbd135b --- /dev/null +++ b/ruff.toml @@ -0,0 +1 @@ +exclude = [".claude"] diff --git a/sync_to_workspace.py b/sync_to_workspace.py index 94d1933..9c8adb5 100644 --- a/sync_to_workspace.py +++ b/sync_to_workspace.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """Sync a project directory to Databricks Workspace.""" -import os + import sys import subprocess from pathlib import Path @@ -12,7 +12,7 @@ error_log = Path.home() / ".sync-errors.log" with open(error_log, "a") as f: f.write(f"databricks-sdk not installed for {sys.executable}\n") - print(f"⚠ databricks-sdk not available", file=sys.stderr) + print("⚠ databricks-sdk not available", file=sys.stderr) sys.exit(0) @@ -53,7 +53,7 @@ def sync_project(project_path: Path): error_log = Path.home() / ".sync-errors.log" with open(error_log, "a") as f: f.write(f"{project_path}: {e}\n") - print(f"⚠ Sync failed (logged to ~/.sync-errors.log)", file=sys.stderr) + print("⚠ Sync failed (logged to ~/.sync-errors.log)", file=sys.stderr) if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py index e50bfac..8531db7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ def app_client(): os.environ.setdefault("DATABRICKS_TOKEN", "dapi_test_token_12345") from app import app + app.config["TESTING"] = True with app.test_client() as client: yield client diff --git a/tests/test_batch_output.py b/tests/test_batch_output.py index c982c39..01e675f 100644 --- a/tests/test_batch_output.py +++ b/tests/test_batch_output.py @@ -6,7 +6,6 @@ """ import time -import pytest class TestBatchOutputEndpoint: @@ -46,9 +45,7 @@ def test_batch_multiple_sessions(self, app_client, create_session): sid2 = create_session() time.sleep(0.3) - resp = app_client.post("/api/output-batch", json={ - "session_ids": [sid1, sid2] - }) + resp = app_client.post("/api/output-batch", json={"session_ids": [sid1, sid2]}) data = resp.get_json() assert resp.status_code == 200 @@ -60,9 +57,9 @@ def test_batch_unknown_session_excluded(self, app_client, create_session): sid = create_session() time.sleep(0.3) - resp = app_client.post("/api/output-batch", json={ - "session_ids": [sid, "nonexistent-session-id"] - }) + resp = app_client.post( + "/api/output-batch", json={"session_ids": [sid, "nonexistent-session-id"]} + ) data = resp.get_json() assert resp.status_code == 200 diff --git a/tests/test_frontend_structure.py b/tests/test_frontend_structure.py index b991382..6b61982 100644 --- a/tests/test_frontend_structure.py +++ b/tests/test_frontend_structure.py @@ -10,8 +10,7 @@ import pytest INDEX_HTML_PATH = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "static", "index.html" + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "index.html" ) @@ -55,12 +54,6 @@ def test_toolbar_element_exists(self, html_source): def test_layout_buttons_exist(self, html_source): """Buttons or controls for switching layouts are present.""" # Should have clickable elements for each layout - layout_button_patterns = [ - r'single.*?(?:button|btn|click)', - r'hsplit.*?(?:button|btn|click)', - r'vsplit.*?(?:button|btn|click)', - r'quad.*?(?:button|btn|click)', - ] # At minimum, all 4 layout names should appear near interactive elements for layout in ["single", "hsplit", "vsplit", "quad"]: count = html_source.lower().count(layout) @@ -71,9 +64,11 @@ def test_layout_buttons_exist(self, html_source): def test_dark_theme_toolbar(self, html_source): """Toolbar uses the dark theme (#1e1e1e or similar dark background).""" - assert "#1e1e1e" in html_source or "#252525" in html_source or "#2d2d2d" in html_source, ( - "Toolbar does not use dark theme colors" - ) + assert ( + "#1e1e1e" in html_source + or "#252525" in html_source + or "#2d2d2d" in html_source + ), "Toolbar does not use dark theme colors" class TestPaneLifecycle: @@ -83,7 +78,11 @@ def test_session_creation_per_pane(self, html_source): """Code creates sessions via /api/session for each pane.""" assert "/api/session" in html_source, "No /api/session call found" # Should create session as part of pane initialization - assert "createSession" in html_source or "create_session" in html_source or "api/session" in html_source + assert ( + "createSession" in html_source + or "create_session" in html_source + or "api/session" in html_source + ) def test_session_close_on_pane_removal(self, html_source): """Code calls /api/session/close when a pane is closed.""" @@ -97,9 +96,11 @@ def test_add_pane_button_exists(self, html_source): def test_pane_class_or_constructor(self, html_source): """A TerminalPane class or equivalent constructor exists.""" - assert "TerminalPane" in html_source or "terminalPane" in html_source or "createPane" in html_source, ( - "No TerminalPane class or pane constructor found" - ) + assert ( + "TerminalPane" in html_source + or "terminalPane" in html_source + or "createPane" in html_source + ), "No TerminalPane class or pane constructor found" class TestIndependentResize: @@ -118,16 +119,15 @@ def test_resize_api_called(self, html_source): def test_resize_debounce(self, html_source): """Resize events are debounced (setTimeout or debounce pattern).""" # Look for debounce implementation - has_debounce = ( - "debounce" in html_source.lower() or - ("setTimeout" in html_source and "resize" in html_source.lower()) + has_debounce = "debounce" in html_source.lower() or ( + "setTimeout" in html_source and "resize" in html_source.lower() ) assert has_debounce, "No resize debounce mechanism found" def test_debounce_delay_at_least_150ms(self, html_source): """Debounce delay is at least 150ms.""" # Find numbers near resize/debounce context - delays = re.findall(r'(\d+)', html_source) + delays = re.findall(r"(\d+)", html_source) # 150 or higher should appear somewhere in debounce context assert any(int(d) >= 150 for d in delays if d.isdigit() and int(d) < 5000), ( "No debounce delay >= 150ms found" @@ -140,9 +140,9 @@ class TestFocusManagement: def test_focus_visual_indicator(self, html_source): """Focused pane has a visual border or highlight.""" has_focus_style = ( - "focused" in html_source.lower() or - "active-pane" in html_source or - "focus" in html_source.lower() + "focused" in html_source.lower() + or "active-pane" in html_source + or "focus" in html_source.lower() ) assert has_focus_style, "No focus visual indicator found" @@ -150,19 +150,23 @@ def test_keyboard_shortcut_cycle(self, html_source): """Ctrl+Shift+N keyboard shortcut is handled.""" # Should check for keydown handler with Ctrl+Shift+N has_shortcut = ( - "ctrlKey" in html_source and - "shiftKey" in html_source and - ("KeyN" in html_source or "key === 'N'" in html_source or - "key ===\"N\"" in html_source or "keyCode" in html_source or - "'n'" in html_source or "'N'" in html_source) + "ctrlKey" in html_source + and "shiftKey" in html_source + and ( + "KeyN" in html_source + or "key === 'N'" in html_source + or 'key ==="N"' in html_source + or "keyCode" in html_source + or "'n'" in html_source + or "'N'" in html_source + ) ) assert has_shortcut, "No Ctrl+Shift+N keyboard shortcut handler found" def test_click_to_focus(self, html_source): """Click handler on panes sets focus.""" has_click_focus = ( - "click" in html_source.lower() and - "focus" in html_source.lower() + "click" in html_source.lower() and "focus" in html_source.lower() ) assert has_click_focus, "No click-to-focus handler found" @@ -172,20 +176,21 @@ class TestClosePane: def test_close_button_exists(self, html_source): """Each pane has a close button (X or similar).""" - has_close = ( - "close" in html_source.lower() and - ("X" in html_source or "x" in html_source or - "✕" in html_source or "\\u00d7" in html_source or - "times" in html_source) + has_close = "close" in html_source.lower() and ( + "X" in html_source + or "x" in html_source + or "✕" in html_source + or "\\u00d7" in html_source + or "times" in html_source ) assert has_close, "No close button found for panes" def test_pane_header_exists(self, html_source): """Each pane has a header/title bar.""" has_header = ( - "pane-header" in html_source or - "paneHeader" in html_source or - "terminal-header" in html_source + "pane-header" in html_source + or "paneHeader" in html_source + or "terminal-header" in html_source ) assert has_header, "No pane header element found" @@ -193,14 +198,14 @@ def test_last_pane_auto_creates_new(self, html_source): """Closing the last pane auto-creates a new terminal.""" # Look for logic that prevents zero panes has_auto_create = ( - "length === 0" in html_source or - "length == 0" in html_source or - "no active" in html_source.lower() or - "last pane" in html_source.lower() or - "at least" in html_source.lower() or - "activePanes" in html_source or - "panes.size === 0" in html_source or - "panes.size == 0" in html_source + "length === 0" in html_source + or "length == 0" in html_source + or "no active" in html_source.lower() + or "last pane" in html_source.lower() + or "at least" in html_source.lower() + or "activePanes" in html_source + or "panes.size === 0" in html_source + or "panes.size == 0" in html_source ) assert has_auto_create, ( "No auto-create logic found for when the last pane is closed" @@ -235,13 +240,13 @@ def test_poll_interval_100ms(self, html_source): def test_poll_pauses_when_no_sessions(self, html_source): """Polling skips/pauses when there are no active sessions.""" has_skip_logic = ( - "length === 0" in html_source or - "length == 0" in html_source or - "no session" in html_source.lower() or - "size === 0" in html_source or - "size == 0" in html_source or - "!sessionIds" in html_source or - "sessionIds.length" in html_source + "length === 0" in html_source + or "length == 0" in html_source + or "no session" in html_source.lower() + or "size === 0" in html_source + or "size == 0" in html_source + or "!sessionIds" in html_source + or "sessionIds.length" in html_source ) assert has_skip_logic, "No logic to pause polling when no sessions are active" @@ -253,7 +258,8 @@ def test_loading_html_unchanged(self): """loading.html exists and was not modified by this feature.""" loading_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "static", "loading.html" + "static", + "loading.html", ) assert os.path.exists(loading_path), "loading.html is missing" # Just verify it still exists -- visual testing will confirm content diff --git a/tests/test_git_credential_helper.py b/tests/test_git_credential_helper.py index 9e2c4c8..6491ad3 100644 --- a/tests/test_git_credential_helper.py +++ b/tests/test_git_credential_helper.py @@ -12,8 +12,6 @@ import os import stat import subprocess -import tempfile -import textwrap from unittest.mock import patch, MagicMock import pytest @@ -32,6 +30,7 @@ def _mock_setup_git_config(tmp_path, monkeypatch): with patch("databricks.sdk.WorkspaceClient", return_value=mock_client): from app import _setup_git_config + _setup_git_config() @@ -148,8 +147,12 @@ def test_git_token_preferred_for_matching_host(self, helper_script): input="protocol=https\nhost=github.com\n\n", capture_output=True, text=True, - env={**os.environ, "GIT_TOKEN": "ghp_enterprise", "GIT_TOKEN_HOST": "github.com", - "DATABRICKS_TOKEN": "dapi_fallback"}, + env={ + **os.environ, + "GIT_TOKEN": "ghp_enterprise", + "GIT_TOKEN_HOST": "github.com", + "DATABRICKS_TOKEN": "dapi_fallback", + }, timeout=5, ) assert result.returncode == 0 @@ -162,8 +165,12 @@ def test_git_token_not_used_for_non_matching_host(self, helper_script): input="protocol=https\nhost=dev.azure.com\n\n", capture_output=True, text=True, - env={**os.environ, "GIT_TOKEN": "ghp_enterprise", "GIT_TOKEN_HOST": "github.com", - "DATABRICKS_TOKEN": "dapi_fallback"}, + env={ + **os.environ, + "GIT_TOKEN": "ghp_enterprise", + "GIT_TOKEN_HOST": "github.com", + "DATABRICKS_TOKEN": "dapi_fallback", + }, timeout=5, ) assert result.returncode == 0 @@ -177,8 +184,11 @@ def test_git_token_without_host_filter_applies_to_all(self, helper_script): input=f"protocol=https\nhost={host}\n\n", capture_output=True, text=True, - env={**os.environ, "GIT_TOKEN": "ghp_universal", - "DATABRICKS_TOKEN": "dapi_should_not_use"}, + env={ + **os.environ, + "GIT_TOKEN": "ghp_universal", + "DATABRICKS_TOKEN": "dapi_should_not_use", + }, timeout=5, ) assert result.returncode == 0 From 99dd39d4097549db9e145b9c46be23a3555b042e Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sat, 7 Mar 2026 22:20:52 +1100 Subject: [PATCH 21/39] fix: Remove rate limiter causing terminal input lag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-user app behind Databricks auth proxy — the token-bucket rate limiter (10 req/s) was throttling legitimate terminal I/O (output polling at 100ms + keystrokes exceeds 10 req/s per terminal). Co-Authored-By: Claude Opus 4.6 --- app.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/app.py b/app.py index 1d61b5f..bbe3a78 100644 --- a/app.py +++ b/app.py @@ -26,32 +26,6 @@ GRACEFUL_SHUTDOWN_WAIT = 3 # Seconds to wait after SIGHUP before SIGKILL -# Simple in-memory rate limiter -class RateLimiter: - """Token-bucket rate limiter per IP address.""" - - def __init__(self, rate=10, per=1.0, burst=20): - self._rate = rate - self._per = per - self._burst = burst - self._tokens = {} # ip -> (tokens, last_time) - self._lock = threading.Lock() - - def allow(self, key): - now = time.time() - with self._lock: - tokens, last = self._tokens.get(key, (self._burst, now)) - elapsed = now - last - tokens = min(self._burst, tokens + elapsed * (self._rate / self._per)) - if tokens >= 1: - self._tokens[key] = (tokens - 1, now) - return True - self._tokens[key] = (tokens, now) - return False - - -rate_limiter = RateLimiter(rate=10, per=1.0, burst=20) - # Logging setup logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -719,12 +693,6 @@ def authorize_request(): } ), 403 - # Rate limit API endpoints - if request.path.startswith("/api/") and request.path != "/api/setup-status": - client_ip = request.headers.get("X-Forwarded-For", request.remote_addr) - if not rate_limiter.allow(client_ip): - return jsonify({"error": "Rate limit exceeded"}), 429 - return None From 92231bc9539a55fa9ef89f2a40e6e94a79fdef8e Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sat, 7 Mar 2026 22:28:58 +1100 Subject: [PATCH 22/39] fix: Guard against stale CWD in terminal sessions Add bashrc check that detects deleted CWD and resets to ~/projects. On tmux reattach, send cd command to refresh stale directory reference. Co-Authored-By: Claude Opus 4.6 --- app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app.py b/app.py index bbe3a78..42c711d 100644 --- a/app.py +++ b/app.py @@ -339,6 +339,10 @@ def _setup_git_config(): # Write ~/.bashrc with colored prompt and aliases bashrc_path = os.path.join(home, ".bashrc") with open(bashrc_path, "w") as f: + f.write("# Guard against stale CWD (happens after tmux reattach if dir was recreated)\n") + f.write('if ! cd . 2>/dev/null; then\n') + f.write(' cd ~/projects 2>/dev/null || cd ~\n') + f.write("fi\n\n") f.write("# Colored prompt: user@host:dir$\n") f.write( "PS1='\\[\\033[01;32m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]\\$ '\n" @@ -843,6 +847,14 @@ def create_session(): ) thread.start() + # Fix stale CWD on tmux reattach (dir may have been recreated with new inode) + if reattached: + time.sleep(0.3) + try: + os.write(master_fd, b"cd ~/projects 2>/dev/null\n") + except OSError: + pass + return jsonify({"session_id": session_id, "reattached": reattached}) except Exception as e: return jsonify({"error": str(e)}), 500 From ffed0d0d61c10fd844290614fed755f8f6f2b350 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sun, 8 Mar 2026 14:34:39 +1100 Subject: [PATCH 23/39] fix: Strip OAuth M2M vars when PAT is set for OpenCode Databricks Apps injects both DATABRICKS_TOKEN and CLIENT_ID/SECRET. The Databricks SDK rejects ambiguous auth, breaking OpenCode's provider initialization. Strip OAuth vars when PAT is present. Co-Authored-By: Claude Opus 4.6 --- app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app.py b/app.py index 252f84f..97d47d5 100644 --- a/app.py +++ b/app.py @@ -838,6 +838,11 @@ def create_session(): # Remove Claude Code env vars so the browser terminal isn't seen as nested shell_env.pop("CLAUDECODE", None) shell_env.pop("CLAUDE_CODE_SESSION", None) + # Remove OAuth M2M vars when PAT is set — Databricks SDK rejects + # ambiguous auth ("more than one authorization method configured"). + if shell_env.get("DATABRICKS_TOKEN"): + shell_env.pop("DATABRICKS_CLIENT_ID", None) + shell_env.pop("DATABRICKS_CLIENT_SECRET", None) # Ensure HOME is set correctly if not shell_env.get("HOME") or shell_env["HOME"] == "/": shell_env["HOME"] = "/app/python/source_code" From 11068d3d198aa06d7b2add9d8abda3377a3ce04b Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sun, 8 Mar 2026 15:23:19 +1100 Subject: [PATCH 24/39] fix: Strip OAuth M2M vars in bashrc for OpenCode auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env var stripping in create_session() shell_env wasn't sufficient — tmux server preserves the original process env. Adding unset to .bashrc ensures every interactive shell strips DATABRICKS_CLIENT_ID/SECRET when PAT is configured. Co-Authored-By: Claude Opus 4.6 --- app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app.py b/app.py index 97d47d5..4abf6f2 100644 --- a/app.py +++ b/app.py @@ -362,6 +362,13 @@ def _setup_git_config(): f.write('if ! cd . 2>/dev/null; then\n') f.write(' cd ~/projects 2>/dev/null || cd ~\n') f.write("fi\n\n") + # Strip OAuth M2M vars when PAT is configured — Databricks SDK rejects + # ambiguous auth ("more than one authorization method configured"). + # This must be in .bashrc (not just shell_env) because tmux server + # may preserve the original process environment across reattach. + if os.environ.get("DATABRICKS_TOKEN"): + f.write("# Strip OAuth M2M vars to avoid SDK auth conflict with PAT\n") + f.write("unset DATABRICKS_CLIENT_ID DATABRICKS_CLIENT_SECRET 2>/dev/null\n\n") f.write("# Colored prompt: user@host:dir$\n") f.write( "PS1='\\[\\033[01;32m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]\\$ '\n" From 5f3991a2f85967999cbc1fb067110f7f4d36195b Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sun, 8 Mar 2026 19:39:31 +1100 Subject: [PATCH 25/39] fix: Wrapper script strips OAuth vars for OpenCode Install OpenCode binary as _opencode_real with a shell wrapper that unsets DATABRICKS_CLIENT_ID/SECRET before exec. This fixes "No provider selected" caused by SDK rejecting dual auth config. Co-Authored-By: Claude Opus 4.6 --- setup_opencode.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/setup_opencode.py b/setup_opencode.py index 98a1ea8..332c60f 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -154,15 +154,41 @@ # Copy binary to ~/.local/bin import shutil - shutil.copy2(str(expected_bin), str(opencode_bin)) + # Install real binary as _opencode_real, create wrapper to strip OAuth vars + opencode_real = local_bin / "_opencode_real" + shutil.copy2(str(expected_bin), str(opencode_real)) + opencode_real.chmod(0o755) + + # Write wrapper that strips OAuth M2M vars before exec'ing the real binary. + # Databricks Apps injects both PAT and OAuth M2M env vars, causing the + # Databricks SDK to reject with "more than one authorization method". + opencode_bin.write_text( + "#!/bin/sh\n" + "unset DATABRICKS_CLIENT_ID DATABRICKS_CLIENT_SECRET\n" + 'exec "$(dirname "$0")/_opencode_real" "$@"\n' + ) opencode_bin.chmod(0o755) - logger.info(f" OpenCode CLI installed to {opencode_bin}") + logger.info(f" OpenCode CLI installed to {opencode_bin} (wrapper + _opencode_real)") # Clean up build directory to save space logger.info(" Cleaning up build directory...") subprocess.run(["rm", "-rf", str(build_dir)], check=True) else: logger.info(f"OpenCode CLI already installed at {opencode_bin}") + # Ensure wrapper exists even if binary was cached from previous deploy + opencode_real = local_bin / "_opencode_real" + if not opencode_real.exists() and opencode_bin.exists(): + # Binary exists but no wrapper — convert to wrapper pattern + import shutil as _shutil + + _shutil.move(str(opencode_bin), str(opencode_real)) + opencode_bin.write_text( + "#!/bin/sh\n" + "unset DATABRICKS_CLIENT_ID DATABRICKS_CLIENT_SECRET\n" + 'exec "$(dirname "$0")/_opencode_real" "$@"\n' + ) + opencode_bin.chmod(0o755) + logger.info(f" Converted to wrapper pattern: {opencode_bin}") # 2. Write minimal opencode.json config # The fork's native Databricks provider auto-discovers models from serving endpoints From 3f264e5181aceb2d84a494396348296f025e2f56 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Sun, 8 Mar 2026 19:50:12 +1100 Subject: [PATCH 26/39] fix: Guard against stale CWD in OpenCode wrapper Add cd $HOME to wrapper script to prevent getcwd() errors when opencode is launched from a deleted directory. Co-Authored-By: Claude Opus 4.6 --- setup_opencode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup_opencode.py b/setup_opencode.py index 332c60f..b7f707b 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -164,6 +164,7 @@ # Databricks SDK to reject with "more than one authorization method". opencode_bin.write_text( "#!/bin/sh\n" + "cd \"$HOME\" 2>/dev/null || true\n" "unset DATABRICKS_CLIENT_ID DATABRICKS_CLIENT_SECRET\n" 'exec "$(dirname "$0")/_opencode_real" "$@"\n' ) @@ -184,6 +185,7 @@ _shutil.move(str(opencode_bin), str(opencode_real)) opencode_bin.write_text( "#!/bin/sh\n" + "cd \"$HOME\" 2>/dev/null || true\n" "unset DATABRICKS_CLIENT_ID DATABRICKS_CLIENT_SECRET\n" 'exec "$(dirname "$0")/_opencode_real" "$@"\n' ) From daf0c5b4febdfcf9d2432da463d0144573d05e18 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 14:15:53 +1100 Subject: [PATCH 27/39] feat: Parallel setup + WebSocket terminal I/O - Agent setup scripts run in parallel via ThreadPoolExecutor (~5x faster) - WebSocket transport for terminal I/O with HTTP polling fallback - Socket.IO integration into enterprise LayoutManager/TerminalPane - Gunicorn timeout increased to 120s for WebSocket connections Co-Authored-By: Claude Opus 4.6 --- app.py | 109 ++++++++++++++++++++++++++++++++++++++---- gunicorn.conf.py | 2 +- requirements.txt | 2 + static/index.html | 117 +++++++++++++++++++++++++++++++++++++++------- 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/app.py b/app.py index 4abf6f2..328df01 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,9 @@ import logging import shutil import sys +from concurrent.futures import ThreadPoolExecutor, wait from flask import Flask, send_from_directory, request, jsonify, session +from flask_socketio import SocketIO, emit, join_room, leave_room from werkzeug.utils import secure_filename from collections import deque @@ -32,6 +34,7 @@ app = Flask(__name__, static_folder="static", static_url_path="/static") app.secret_key = os.urandom(24) +socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") # Store sessions: {session_id: {"master_fd": fd, "pid": pid, "output_buffer": deque}} sessions = {} @@ -551,12 +554,23 @@ def run_setup(): ) # Use the currently running interpreter instead of assuming `python` exists in PATH. py = sys.executable or "python" - _run_step("claude", [py, "setup_claude.py"]) - _run_step("codex", [py, "setup_codex.py"]) - _run_step("opencode", [py, "setup_opencode.py"]) - _run_step("gemini", [py, "setup_gemini.py"]) - _run_step("databricks", [py, "setup_databricks.py"]) - _run_step("mlflow", [py, "setup_mlflow.py"]) + + # --- Parallel agent setup (all independent of each other) --- + parallel_steps = [ + ("claude", [py, "setup_claude.py"]), + ("codex", [py, "setup_codex.py"]), + ("opencode", [py, "setup_opencode.py"]), + ("gemini", [py, "setup_gemini.py"]), + ("databricks", [py, "setup_databricks.py"]), + ("mlflow", [py, "setup_mlflow.py"]), + ] + + with ThreadPoolExecutor(max_workers=len(parallel_steps)) as executor: + futures = [ + executor.submit(_run_step, step_id, command) + for step_id, command in parallel_steps + ] + wait(futures) # Clone git repos specified in GIT_REPOS env var _clone_git_repos() @@ -659,11 +673,17 @@ def read_pty_output(session_id, fd): if not output: # EOF — process exited break + decoded = output.decode(errors="replace") with sessions_lock: if session_id in sessions: - sessions[session_id]["output_buffer"].append( - output.decode(errors="replace") - ) + sessions[session_id]["output_buffer"].append(decoded) + # Push via WebSocket to the session room + try: + socketio.emit('terminal_output', + {'session_id': session_id, 'output': decoded}, + room=session_id) + except Exception: + pass # No WebSocket clients — HTTP polling handles it else: # select timed out — check if process is still alive try: @@ -682,6 +702,11 @@ def read_pty_output(session_id, fd): if session_id in sessions: sessions[session_id]["exited"] = True logger.info(f"Session {session_id} process exited") + # Notify WebSocket clients + try: + socketio.emit('session_exited', {'session_id': session_id}, room=session_id) + except Exception: + pass def terminate_session(session_id, pid, master_fd): @@ -1088,6 +1113,70 @@ def close_session(): return jsonify({"status": "ok"}) +# ── WebSocket event handlers ──────────────────────────────────────────────── + +@socketio.on('join_session') +def handle_join_session(data): + """Client joins a session room to receive real-time output.""" + session_id = data.get('session_id') + if not session_id: + return + with sessions_lock: + if session_id not in sessions: + return + sessions[session_id]["last_poll_time"] = time.time() + join_room(session_id) + + +@socketio.on('leave_session') +def handle_leave_session(data): + """Client leaves a session room.""" + session_id = data.get('session_id') + if session_id: + leave_room(session_id) + + +@socketio.on('terminal_input') +def handle_terminal_input(data): + """Receive terminal input via WebSocket.""" + session_id = data.get('session_id') + input_data = data.get('input', '') + if not session_id or len(input_data) > 4096: + return + + with sessions_lock: + if session_id not in sessions: + return + fd = sessions[session_id]["master_fd"] + sessions[session_id]["last_poll_time"] = time.time() + + try: + os.write(fd, input_data.encode()) + except OSError: + pass + + +@socketio.on('terminal_resize') +def handle_terminal_resize(data): + """Resize terminal via WebSocket.""" + session_id = data.get('session_id') + cols = data.get('cols', 80) + rows = data.get('rows', 24) + if not session_id or not isinstance(cols, int) or not isinstance(rows, int): + return + + with sessions_lock: + if session_id not in sessions: + return + fd = sessions[session_id]["master_fd"] + + try: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + except OSError: + pass + + def initialize_app(): """One-time init: resolve auth, detect owner, start cleanup + token refresh.""" global app_owner, token_refresher @@ -1140,4 +1229,4 @@ def initialize_app(): # Local dev only — production uses gunicorn initialize_app() port = int(os.environ.get("DATABRICKS_APP_PORT", 8000)) - app.run(host="0.0.0.0", port=port, threaded=True) + socketio.run(app, host="0.0.0.0", port=port) diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 118f4ce..52a69ed 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -5,7 +5,7 @@ workers = 1 # PTY fds + sessions dict are process-local threads = 32 # Support 20+ concurrent terminals polling + input + resize worker_class = "gthread" -timeout = 30 +timeout = 120 # WebSocket connections are long-lived; 30s was too aggressive graceful_timeout = 10 # Databricks gives 15s after SIGTERM accesslog = "-" errorlog = "-" diff --git a/requirements.txt b/requirements.txt index 5673e9f..7e392f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ flask==3.1.3 +flask-socketio>=5.3 +simple-websocket>=1.0 claude-agent-sdk==0.1.46 databricks-sdk==0.96.0 mlflow[genai]>=3.4 diff --git a/static/index.html b/static/index.html index 79217aa..0c7d67d 100644 --- a/static/index.html +++ b/static/index.html @@ -173,6 +173,7 @@
Loading...
+ @@ -267,28 +268,41 @@ if (!this.sessionId) return; // Drop OSC responses: \x1b] ... \x1b\ or \x1b] ... \x07 if (/\x1b\]/.test(input)) return; - fetch('/api/input', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: this.sessionId, input: input }) - }).catch(err => console.warn('Input send failed:', err)); + // Use WebSocket if connected, else HTTP fallback + if (window._wsConnected && window._socket) { + window._socket.emit('terminal_input', { session_id: this.sessionId, input: input }); + } else { + fetch('/api/input', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId, input: input }) + }).catch(err => console.warn('Input send failed:', err)); + } }); } async sendResize() { if (!this.sessionId || !this.term) return; - try { - await fetch('/api/resize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: this.sessionId, - cols: this.term.cols, - rows: this.term.rows - }) + if (window._wsConnected && window._socket) { + window._socket.emit('terminal_resize', { + session_id: this.sessionId, + cols: this.term.cols, + rows: this.term.rows }); - } catch (err) { - console.warn('Resize failed:', err); + } else { + try { + await fetch('/api/resize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: this.sessionId, + cols: this.term.cols, + rows: this.term.rows + }) + }); + } catch (err) { + console.warn('Resize failed:', err); + } } } @@ -316,6 +330,10 @@ async destroy() { this.alive = false; if (this.sessionId) { + // Leave WebSocket room if connected + if (window._wsConnected && window._socket) { + window._socket.emit('leave_session', { session_id: this.sessionId }); + } try { navigator.sendBeacon( '/api/session/close', @@ -396,15 +414,75 @@ // Cleanup on page unload window.addEventListener('beforeunload', () => this.cleanup()); + // Initialize WebSocket connection (falls back to HTTP polling) + this.initWebSocket(); + // Check for existing tmux sessions to restore await this.restoreOrCreate(); - // Start adaptive batch polling + // Start adaptive batch polling (serves as fallback when WebSocket is down) this.startPolling(); this.updateIndicators(); } + initWebSocket() { + if (typeof io === 'undefined') { + console.log('[ws] Socket.IO client not available, using HTTP polling'); + return; + } + + const socket = io({ transports: ['websocket', 'polling'] }); + window._socket = socket; + window._wsConnected = false; + + socket.on('connect', () => { + console.log('[ws] Connected'); + window._wsConnected = true; + // Re-join rooms for all active panes and stop HTTP polling + for (const [idx, pane] of this.panes) { + if (pane && pane.sessionId) { + socket.emit('join_session', { session_id: pane.sessionId }); + } + } + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + }); + + socket.on('disconnect', (reason) => { + console.log('[ws] Disconnected:', reason); + window._wsConnected = false; + // Fall back to HTTP polling + this.startPolling(); + }); + + socket.on('connect_error', (err) => { + console.log('[ws] Connection error:', err.message); + window._wsConnected = false; + if (!this.pollTimer) this.startPolling(); + }); + + // Receive terminal output pushed from server + socket.on('terminal_output', (data) => { + for (const [idx, pane] of this.panes) { + if (pane && pane.sessionId === data.session_id && data.output) { + pane.writeOutput(data.output); + } + } + }); + + // Receive session exited notification + socket.on('session_exited', (data) => { + for (const [idx, pane] of this.panes) { + if (pane && pane.sessionId === data.session_id) { + pane.markExited(); + } + } + }); + } + async restoreOrCreate() { // Load saved layout from localStorage const savedLayout = localStorage.getItem('terminal-layout'); @@ -540,6 +618,11 @@ this.panes.set(index, pane); await pane.init(this.container); + // Join WebSocket room if connected + if (window._wsConnected && window._socket && pane.sessionId) { + window._socket.emit('join_session', { session_id: pane.sessionId }); + } + // Reorder DOM children to match slot order this.reorderSlots(); From 4f66e57de2706d918a793a1c7065abfd393c5e30 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 15:11:39 +1100 Subject: [PATCH 28/39] feat: Add Databricks MCP server to Claude + OpenCode Install ai-dev-kit databricks-mcp-server (sparse clone + venv) as a parallel setup step. Configure all 3 MCP servers (deepwiki, exa, databricks) for both Claude Code and OpenCode. Add new config files to TokenRefresher so OAuth tokens stay fresh in MCP configs. Co-Authored-By: Claude Opus 4.6 --- app.py | 21 +++++--- setup_claude.py | 14 +++++ setup_databricks_mcp.py | 116 ++++++++++++++++++++++++++++++++++++++++ setup_opencode.py | 30 ++++++++++- utils.py | 2 + 5 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 setup_databricks_mcp.py diff --git a/app.py b/app.py index 328df01..b1cb73d 100644 --- a/app.py +++ b/app.py @@ -131,6 +131,14 @@ def handle_sigterm(signum, frame): "completed_at": None, "error": None, }, + { + "id": "databricks_mcp", + "label": "Installing Databricks MCP server", + "status": "pending", + "started_at": None, + "completed_at": None, + "error": None, + }, { "id": "mlflow", "label": "Enabling MLflow tracing", @@ -557,12 +565,13 @@ def run_setup(): # --- Parallel agent setup (all independent of each other) --- parallel_steps = [ - ("claude", [py, "setup_claude.py"]), - ("codex", [py, "setup_codex.py"]), - ("opencode", [py, "setup_opencode.py"]), - ("gemini", [py, "setup_gemini.py"]), - ("databricks", [py, "setup_databricks.py"]), - ("mlflow", [py, "setup_mlflow.py"]), + ("claude", [py, "setup_claude.py"]), + ("codex", [py, "setup_codex.py"]), + ("opencode", [py, "setup_opencode.py"]), + ("gemini", [py, "setup_gemini.py"]), + ("databricks", [py, "setup_databricks.py"]), + ("databricks_mcp", [py, "setup_databricks_mcp.py"]), + ("mlflow", [py, "setup_mlflow.py"]), ] with ThreadPoolExecutor(max_workers=len(parallel_steps)) as executor: diff --git a/setup_claude.py b/setup_claude.py index ee2ff28..16e6c8c 100644 --- a/setup_claude.py +++ b/setup_claude.py @@ -54,11 +54,25 @@ settings_path.write_text(json.dumps(settings, indent=2)) # 2. Write ~/.claude.json with onboarding skip AND MCP servers +# Databricks MCP server paths (installed by setup_databricks_mcp.py in parallel) +ai_dev_kit_dir = home / ".ai-dev-kit" +dbx_mcp_python = str(ai_dev_kit_dir / ".venv" / "bin" / "python") +dbx_mcp_server = str(ai_dev_kit_dir / "repo" / "databricks-mcp-server" / "run_server.py") + claude_json = { "hasCompletedOnboarding": True, "mcpServers": { "deepwiki": {"type": "http", "url": "https://mcp.deepwiki.com/mcp"}, "exa": {"type": "http", "url": "https://mcp.exa.ai/mcp"}, + "databricks": { + "command": dbx_mcp_python, + "args": [dbx_mcp_server], + "defer_loading": True, + "env": { + "DATABRICKS_HOST": databricks_host or "", + "DATABRICKS_TOKEN": auth_token or "", + }, + }, }, } diff --git a/setup_databricks_mcp.py b/setup_databricks_mcp.py new file mode 100644 index 0000000..e37a0d9 --- /dev/null +++ b/setup_databricks_mcp.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +"""Install the Databricks MCP server from ai-dev-kit. + +Clones databricks-solutions/ai-dev-kit, creates a venv, and installs +databricks-tools-core + databricks-mcp-server. The MCP server is then +available as a stdio server for Claude Code, OpenCode, Gemini CLI, etc. + +Reference: https://github.com/databricks-solutions/ai-dev-kit/tree/main/databricks-mcp-server +""" + +import logging +import os +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Set HOME if not properly set +if not os.environ.get("HOME") or os.environ["HOME"] == "/": + os.environ["HOME"] = "/app/python/source_code" + +home = Path(os.environ["HOME"]) + +AI_DEV_KIT_DIR = home / ".ai-dev-kit" +REPO_DIR = AI_DEV_KIT_DIR / "repo" +VENV_DIR = AI_DEV_KIT_DIR / ".venv" +VENV_PYTHON = VENV_DIR / "bin" / "python" +RUN_SERVER = REPO_DIR / "databricks-mcp-server" / "run_server.py" + +REPO_URL = "https://github.com/databricks-solutions/ai-dev-kit.git" + +env = {**os.environ, "HOME": str(home)} + + +def is_installed(): + """Check if the MCP server is already installed and functional.""" + return VENV_PYTHON.exists() and RUN_SERVER.exists() + + +if is_installed(): + logger.info(f"Databricks MCP server already installed at {AI_DEV_KIT_DIR}") +else: + logger.info("Installing Databricks MCP server from ai-dev-kit...") + AI_DEV_KIT_DIR.mkdir(parents=True, exist_ok=True) + + # 1. Clone the repo (sparse checkout — only what we need) + if not REPO_DIR.exists(): + logger.info(f" Cloning {REPO_URL}...") + result = subprocess.run( + [ + "git", "clone", "--depth=1", + "--filter=blob:none", + "--sparse", + REPO_URL, + str(REPO_DIR), + ], + capture_output=True, text=True, env=env, + ) + if result.returncode != 0: + logger.error(f" git clone failed: {result.stderr}") + raise SystemExit(1) + + # Only check out the directories we need + subprocess.run( + ["git", "sparse-checkout", "set", + "databricks-tools-core", "databricks-mcp-server"], + capture_output=True, text=True, cwd=str(REPO_DIR), env=env, check=True, + ) + logger.info(" Repo cloned (sparse: databricks-tools-core + databricks-mcp-server)") + else: + logger.info(" Repo already cloned, pulling latest...") + subprocess.run( + ["git", "pull", "--ff-only"], + capture_output=True, text=True, cwd=str(REPO_DIR), env=env, + ) + + # 2. Create venv + if not VENV_PYTHON.exists(): + logger.info(" Creating venv...") + result = subprocess.run( + ["python3", "-m", "venv", str(VENV_DIR)], + capture_output=True, text=True, env=env, + ) + if result.returncode != 0: + logger.error(f" venv creation failed: {result.stderr}") + raise SystemExit(1) + + # 3. Install packages into venv + logger.info(" Installing databricks-tools-core...") + result = subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "install", "-q", + "-e", str(REPO_DIR / "databricks-tools-core")], + capture_output=True, text=True, env=env, + ) + if result.returncode != 0: + logger.error(f" databricks-tools-core install failed: {result.stderr}") + raise SystemExit(1) + + logger.info(" Installing databricks-mcp-server...") + result = subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "install", "-q", + "-e", str(REPO_DIR / "databricks-mcp-server")], + capture_output=True, text=True, env=env, + ) + if result.returncode != 0: + logger.error(f" databricks-mcp-server install failed: {result.stderr}") + raise SystemExit(1) + + logger.info(f"Databricks MCP server installed: {RUN_SERVER}") + +# Export paths for other setup scripts to reference +DATABRICKS_MCP_PYTHON = str(VENV_PYTHON) +DATABRICKS_MCP_SERVER_SCRIPT = str(RUN_SERVER) + +logger.info(f" venv python: {DATABRICKS_MCP_PYTHON}") +logger.info(f" server script: {DATABRICKS_MCP_SERVER_SCRIPT}") diff --git a/setup_opencode.py b/setup_opencode.py index b7f707b..767642f 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -192,17 +192,42 @@ opencode_bin.chmod(0o755) logger.info(f" Converted to wrapper pattern: {opencode_bin}") -# 2. Write minimal opencode.json config +# 2. Write opencode.json config with MCP servers # The fork's native Databricks provider auto-discovers models from serving endpoints # and handles auth via DATABRICKS_TOKEN env var / ~/.databrickscfg / SDK credential chain. -# We just need to enable the provider and set a default model. opencode_config_dir = home / ".config" / "opencode" opencode_config_dir.mkdir(parents=True, exist_ok=True) +# Databricks MCP server paths (installed by setup_databricks_mcp.py in parallel) +ai_dev_kit_dir = home / ".ai-dev-kit" +dbx_mcp_python = str(ai_dev_kit_dir / ".venv" / "bin" / "python") +dbx_mcp_server = str(ai_dev_kit_dir / "repo" / "databricks-mcp-server" / "run_server.py") + opencode_config = { "$schema": "https://opencode.ai/config.json", "enabled_providers": ["databricks"], "model": f"databricks/{anthropic_model}", + "mcp": { + "deepwiki": { + "type": "remote", + "url": "https://mcp.deepwiki.com/mcp", + "enabled": True, + }, + "exa": { + "type": "remote", + "url": "https://mcp.exa.ai/mcp", + "enabled": True, + }, + "databricks": { + "type": "local", + "command": [dbx_mcp_python, dbx_mcp_server], + "environment": { + "DATABRICKS_HOST": host, + "DATABRICKS_TOKEN": token, + }, + "enabled": True, + }, + }, } config_path = opencode_config_dir / "opencode.json" @@ -210,6 +235,7 @@ logger.info(f"OpenCode configured: {config_path}") logger.info(" Provider: databricks (native, auto-discovers models)") logger.info(f" Default model: databricks/{anthropic_model}") +logger.info(" MCP servers: deepwiki, exa, databricks") logger.info(f"OpenCode ready! Default model: {anthropic_model}") logger.info(" opencode # Start OpenCode TUI") diff --git a/utils.py b/utils.py index d613663..cb32827 100644 --- a/utils.py +++ b/utils.py @@ -230,9 +230,11 @@ def _update_all_token_files(old_token: str, new_token: str): config_files = [ home / ".claude" / "settings.json", # ANTHROPIC_AUTH_TOKEN + home / ".claude.json", # databricks MCP server DATABRICKS_TOKEN home / ".gemini" / ".env", # GEMINI_API_KEY home / ".codex" / ".env", # OPENAI_API_KEY home / ".local" / "share" / "opencode" / "auth.json", # api_key + home / ".config" / "opencode" / "opencode.json", # MCP server DATABRICKS_TOKEN home / ".databrickscfg", # token ] From f97b9a0221f32944bc320982446e93f947ad6c28 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 15:48:01 +1100 Subject: [PATCH 29/39] refactor: Drop databricks MCP, keep deepwiki+exa for Claude+OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Databricks CLI is already available in the terminal — agents can use it directly. Remove the ai-dev-kit MCP server to avoid unnecessary clone+venv overhead. Keep the two zero-install HTTP MCPs (deepwiki, exa) and add them to OpenCode alongside Claude Code. Co-Authored-By: Claude Opus 4.6 --- app.py | 21 +++----- setup_claude.py | 14 ----- setup_databricks_mcp.py | 116 ---------------------------------------- setup_opencode.py | 16 +----- utils.py | 2 - 5 files changed, 7 insertions(+), 162 deletions(-) delete mode 100644 setup_databricks_mcp.py diff --git a/app.py b/app.py index b1cb73d..328df01 100644 --- a/app.py +++ b/app.py @@ -131,14 +131,6 @@ def handle_sigterm(signum, frame): "completed_at": None, "error": None, }, - { - "id": "databricks_mcp", - "label": "Installing Databricks MCP server", - "status": "pending", - "started_at": None, - "completed_at": None, - "error": None, - }, { "id": "mlflow", "label": "Enabling MLflow tracing", @@ -565,13 +557,12 @@ def run_setup(): # --- Parallel agent setup (all independent of each other) --- parallel_steps = [ - ("claude", [py, "setup_claude.py"]), - ("codex", [py, "setup_codex.py"]), - ("opencode", [py, "setup_opencode.py"]), - ("gemini", [py, "setup_gemini.py"]), - ("databricks", [py, "setup_databricks.py"]), - ("databricks_mcp", [py, "setup_databricks_mcp.py"]), - ("mlflow", [py, "setup_mlflow.py"]), + ("claude", [py, "setup_claude.py"]), + ("codex", [py, "setup_codex.py"]), + ("opencode", [py, "setup_opencode.py"]), + ("gemini", [py, "setup_gemini.py"]), + ("databricks", [py, "setup_databricks.py"]), + ("mlflow", [py, "setup_mlflow.py"]), ] with ThreadPoolExecutor(max_workers=len(parallel_steps)) as executor: diff --git a/setup_claude.py b/setup_claude.py index 16e6c8c..ee2ff28 100644 --- a/setup_claude.py +++ b/setup_claude.py @@ -54,25 +54,11 @@ settings_path.write_text(json.dumps(settings, indent=2)) # 2. Write ~/.claude.json with onboarding skip AND MCP servers -# Databricks MCP server paths (installed by setup_databricks_mcp.py in parallel) -ai_dev_kit_dir = home / ".ai-dev-kit" -dbx_mcp_python = str(ai_dev_kit_dir / ".venv" / "bin" / "python") -dbx_mcp_server = str(ai_dev_kit_dir / "repo" / "databricks-mcp-server" / "run_server.py") - claude_json = { "hasCompletedOnboarding": True, "mcpServers": { "deepwiki": {"type": "http", "url": "https://mcp.deepwiki.com/mcp"}, "exa": {"type": "http", "url": "https://mcp.exa.ai/mcp"}, - "databricks": { - "command": dbx_mcp_python, - "args": [dbx_mcp_server], - "defer_loading": True, - "env": { - "DATABRICKS_HOST": databricks_host or "", - "DATABRICKS_TOKEN": auth_token or "", - }, - }, }, } diff --git a/setup_databricks_mcp.py b/setup_databricks_mcp.py deleted file mode 100644 index e37a0d9..0000000 --- a/setup_databricks_mcp.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python -"""Install the Databricks MCP server from ai-dev-kit. - -Clones databricks-solutions/ai-dev-kit, creates a venv, and installs -databricks-tools-core + databricks-mcp-server. The MCP server is then -available as a stdio server for Claude Code, OpenCode, Gemini CLI, etc. - -Reference: https://github.com/databricks-solutions/ai-dev-kit/tree/main/databricks-mcp-server -""" - -import logging -import os -import subprocess -from pathlib import Path - -logger = logging.getLogger(__name__) - -# Set HOME if not properly set -if not os.environ.get("HOME") or os.environ["HOME"] == "/": - os.environ["HOME"] = "/app/python/source_code" - -home = Path(os.environ["HOME"]) - -AI_DEV_KIT_DIR = home / ".ai-dev-kit" -REPO_DIR = AI_DEV_KIT_DIR / "repo" -VENV_DIR = AI_DEV_KIT_DIR / ".venv" -VENV_PYTHON = VENV_DIR / "bin" / "python" -RUN_SERVER = REPO_DIR / "databricks-mcp-server" / "run_server.py" - -REPO_URL = "https://github.com/databricks-solutions/ai-dev-kit.git" - -env = {**os.environ, "HOME": str(home)} - - -def is_installed(): - """Check if the MCP server is already installed and functional.""" - return VENV_PYTHON.exists() and RUN_SERVER.exists() - - -if is_installed(): - logger.info(f"Databricks MCP server already installed at {AI_DEV_KIT_DIR}") -else: - logger.info("Installing Databricks MCP server from ai-dev-kit...") - AI_DEV_KIT_DIR.mkdir(parents=True, exist_ok=True) - - # 1. Clone the repo (sparse checkout — only what we need) - if not REPO_DIR.exists(): - logger.info(f" Cloning {REPO_URL}...") - result = subprocess.run( - [ - "git", "clone", "--depth=1", - "--filter=blob:none", - "--sparse", - REPO_URL, - str(REPO_DIR), - ], - capture_output=True, text=True, env=env, - ) - if result.returncode != 0: - logger.error(f" git clone failed: {result.stderr}") - raise SystemExit(1) - - # Only check out the directories we need - subprocess.run( - ["git", "sparse-checkout", "set", - "databricks-tools-core", "databricks-mcp-server"], - capture_output=True, text=True, cwd=str(REPO_DIR), env=env, check=True, - ) - logger.info(" Repo cloned (sparse: databricks-tools-core + databricks-mcp-server)") - else: - logger.info(" Repo already cloned, pulling latest...") - subprocess.run( - ["git", "pull", "--ff-only"], - capture_output=True, text=True, cwd=str(REPO_DIR), env=env, - ) - - # 2. Create venv - if not VENV_PYTHON.exists(): - logger.info(" Creating venv...") - result = subprocess.run( - ["python3", "-m", "venv", str(VENV_DIR)], - capture_output=True, text=True, env=env, - ) - if result.returncode != 0: - logger.error(f" venv creation failed: {result.stderr}") - raise SystemExit(1) - - # 3. Install packages into venv - logger.info(" Installing databricks-tools-core...") - result = subprocess.run( - [str(VENV_PYTHON), "-m", "pip", "install", "-q", - "-e", str(REPO_DIR / "databricks-tools-core")], - capture_output=True, text=True, env=env, - ) - if result.returncode != 0: - logger.error(f" databricks-tools-core install failed: {result.stderr}") - raise SystemExit(1) - - logger.info(" Installing databricks-mcp-server...") - result = subprocess.run( - [str(VENV_PYTHON), "-m", "pip", "install", "-q", - "-e", str(REPO_DIR / "databricks-mcp-server")], - capture_output=True, text=True, env=env, - ) - if result.returncode != 0: - logger.error(f" databricks-mcp-server install failed: {result.stderr}") - raise SystemExit(1) - - logger.info(f"Databricks MCP server installed: {RUN_SERVER}") - -# Export paths for other setup scripts to reference -DATABRICKS_MCP_PYTHON = str(VENV_PYTHON) -DATABRICKS_MCP_SERVER_SCRIPT = str(RUN_SERVER) - -logger.info(f" venv python: {DATABRICKS_MCP_PYTHON}") -logger.info(f" server script: {DATABRICKS_MCP_SERVER_SCRIPT}") diff --git a/setup_opencode.py b/setup_opencode.py index 767642f..4e4196d 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -198,11 +198,6 @@ opencode_config_dir = home / ".config" / "opencode" opencode_config_dir.mkdir(parents=True, exist_ok=True) -# Databricks MCP server paths (installed by setup_databricks_mcp.py in parallel) -ai_dev_kit_dir = home / ".ai-dev-kit" -dbx_mcp_python = str(ai_dev_kit_dir / ".venv" / "bin" / "python") -dbx_mcp_server = str(ai_dev_kit_dir / "repo" / "databricks-mcp-server" / "run_server.py") - opencode_config = { "$schema": "https://opencode.ai/config.json", "enabled_providers": ["databricks"], @@ -218,15 +213,6 @@ "url": "https://mcp.exa.ai/mcp", "enabled": True, }, - "databricks": { - "type": "local", - "command": [dbx_mcp_python, dbx_mcp_server], - "environment": { - "DATABRICKS_HOST": host, - "DATABRICKS_TOKEN": token, - }, - "enabled": True, - }, }, } @@ -235,7 +221,7 @@ logger.info(f"OpenCode configured: {config_path}") logger.info(" Provider: databricks (native, auto-discovers models)") logger.info(f" Default model: databricks/{anthropic_model}") -logger.info(" MCP servers: deepwiki, exa, databricks") +logger.info(" MCP servers: deepwiki, exa") logger.info(f"OpenCode ready! Default model: {anthropic_model}") logger.info(" opencode # Start OpenCode TUI") diff --git a/utils.py b/utils.py index cb32827..d613663 100644 --- a/utils.py +++ b/utils.py @@ -230,11 +230,9 @@ def _update_all_token_files(old_token: str, new_token: str): config_files = [ home / ".claude" / "settings.json", # ANTHROPIC_AUTH_TOKEN - home / ".claude.json", # databricks MCP server DATABRICKS_TOKEN home / ".gemini" / ".env", # GEMINI_API_KEY home / ".codex" / ".env", # OPENAI_API_KEY home / ".local" / "share" / "opencode" / "auth.json", # api_key - home / ".config" / "opencode" / "opencode.json", # MCP server DATABRICKS_TOKEN home / ".databrickscfg", # token ] From 10a3db4baabf428fcd41a1c7c03c8c21aa8de153 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 19:43:53 +1100 Subject: [PATCH 30/39] fix: Prefer HTTP POST over SocketIO long-polling for input When Databricks Apps proxy doesn't support WebSocket, SocketIO falls back to HTTP long-polling which has more overhead than simple POST. Now tracks real WebSocket vs long-polling transport and only uses SocketIO when true WebSocket is active. HTTP batch polling (50ms) handles output otherwise. Co-Authored-By: Claude Opus 4.6 --- static/index.html | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/static/index.html b/static/index.html index 0c7d67d..7f2547a 100644 --- a/static/index.html +++ b/static/index.html @@ -268,8 +268,9 @@ if (!this.sessionId) return; // Drop OSC responses: \x1b] ... \x1b\ or \x1b] ... \x07 if (/\x1b\]/.test(input)) return; - // Use WebSocket if connected, else HTTP fallback - if (window._wsConnected && window._socket) { + // Use true WebSocket if available, else HTTP POST (always prefer HTTP + // over SocketIO long-polling — simpler and lower overhead) + if (window._wsRealWebSocket && window._socket) { window._socket.emit('terminal_input', { session_id: this.sessionId, input: input }); } else { fetch('/api/input', { @@ -283,7 +284,7 @@ async sendResize() { if (!this.sessionId || !this.term) return; - if (window._wsConnected && window._socket) { + if (window._wsRealWebSocket && window._socket) { window._socket.emit('terminal_resize', { session_id: this.sessionId, cols: this.term.cols, @@ -371,7 +372,7 @@ }; // Adaptive polling intervals (ms) - const POLL_FOCUSED = 100; // Focused pane — fast updates + const POLL_FOCUSED = 50; // Focused pane — fast updates const POLL_UNFOCUSED = 500; // Visible but unfocused panes const POLL_HIDDEN = 2000; // Browser tab is hidden @@ -432,28 +433,49 @@ return; } + // Try WebSocket first, fall back to long-polling const socket = io({ transports: ['websocket', 'polling'] }); window._socket = socket; window._wsConnected = false; + window._wsRealWebSocket = false; // true only when using real WebSocket transport socket.on('connect', () => { - console.log('[ws] Connected'); + const transport = socket.io.engine.transport.name; + console.log('[ws] Connected via', transport); window._wsConnected = true; - // Re-join rooms for all active panes and stop HTTP polling + window._wsRealWebSocket = (transport === 'websocket'); + + // Re-join rooms for all active panes for (const [idx, pane] of this.panes) { if (pane && pane.sessionId) { socket.emit('join_session', { session_id: pane.sessionId }); } } - if (this.pollTimer) { + + // Only stop HTTP polling when on true WebSocket — SocketIO long-polling + // has more overhead than our simple batch poll + if (window._wsRealWebSocket && this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } }); + // Detect transport upgrade (long-polling → WebSocket) + socket.io.on('open', () => { + socket.io.engine.on('upgrade', () => { + console.log('[ws] Upgraded to WebSocket'); + window._wsRealWebSocket = true; + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + }); + }); + socket.on('disconnect', (reason) => { console.log('[ws] Disconnected:', reason); window._wsConnected = false; + window._wsRealWebSocket = false; // Fall back to HTTP polling this.startPolling(); }); @@ -461,11 +483,14 @@ socket.on('connect_error', (err) => { console.log('[ws] Connection error:', err.message); window._wsConnected = false; + window._wsRealWebSocket = false; if (!this.pollTimer) this.startPolling(); }); - // Receive terminal output pushed from server + // Receive terminal output pushed from server — only use when on true + // WebSocket; when on long-polling, batch HTTP poll handles output socket.on('terminal_output', (data) => { + if (!window._wsRealWebSocket) return; for (const [idx, pane] of this.panes) { if (pane && pane.sessionId === data.session_id && data.output) { pane.writeOutput(data.output); @@ -473,7 +498,7 @@ } }); - // Receive session exited notification + // Receive session exited notification (always handle, lightweight) socket.on('session_exited', (data) => { for (const [idx, pane] of this.panes) { if (pane && pane.sessionId === data.session_id) { From f37459992a587be0cb727440ddc42b2918f5f4ff Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 19:51:22 +1100 Subject: [PATCH 31/39] perf: Combine input+output in single HTTP round trip The /api/input endpoint now writes to PTY, waits 5ms for echo, drains the output buffer, and returns output in the response. Client renders the echo immediately instead of waiting for the next batch poll cycle, halving perceived keystroke latency. Co-Authored-By: Claude Opus 4.6 --- app.py | 26 ++++++++++++++++++++++++-- static/index.html | 2 ++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 328df01..dd91ab1 100644 --- a/app.py +++ b/app.py @@ -948,7 +948,12 @@ def create_session(): @app.route("/api/input", methods=["POST"]) def send_input(): - """Send input to the terminal.""" + """Send input to the terminal and return any immediate output. + + Writes input to the PTY, waits briefly for the echo/response, and returns + any available output in the same response. This halves the perceived + keystroke latency by combining two HTTP round-trips into one. + """ data = request.json session_id = data.get("session_id") input_data = data.get("input", "") @@ -960,13 +965,30 @@ def send_input(): return jsonify({"error": "Session not found"}), 404 fd = sessions[session_id]["master_fd"] + sessions[session_id]["last_poll_time"] = time.time() try: os.write(fd, input_data.encode()) - return jsonify({"status": "ok"}) except OSError as e: return jsonify({"error": str(e)}), 500 + # Wait briefly for PTY to echo, then drain the output buffer. + # The reader thread appends output asynchronously; a short sleep + # lets it capture the echo before we drain. + time.sleep(0.005) # 5ms — enough for local PTY echo + + with sessions_lock: + if session_id not in sessions: + return jsonify({"status": "ok", "output": ""}) + session = sessions[session_id] + session["last_poll_time"] = time.time() + buffer = session["output_buffer"] + output = "".join(buffer) + buffer.clear() + exited = session.get("exited", False) + + return jsonify({"status": "ok", "output": output, "exited": exited}) + @app.route("/api/upload", methods=["POST"]) def upload_file(): diff --git a/static/index.html b/static/index.html index 7f2547a..191434a 100644 --- a/static/index.html +++ b/static/index.html @@ -277,6 +277,8 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: this.sessionId, input: input }) + }).then(resp => resp.json()).then(data => { + if (data.output) this.writeOutput(data.output); }).catch(err => console.warn('Input send failed:', err)); } }); From ed1c93008eb65e247177cd6f71a9cb293a1b02a9 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 21:04:51 +1100 Subject: [PATCH 32/39] perf: Bundle Socket.IO client locally, add input batching - Serve socket.io.min.js from /static/ instead of CDN to bypass Databricks Apps CSP (script-src 'self') that was blocking it - Batch keystrokes within one animation frame (~16ms) on HTTP fallback path to reduce round trips through Azure proxy Co-Authored-By: Claude Opus 4.6 --- static/index.html | 36 +++++++++++++++++++++++++----------- static/socket.io.min.js | 7 +++++++ 2 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 static/socket.io.min.js diff --git a/static/index.html b/static/index.html index 191434a..196f42f 100644 --- a/static/index.html +++ b/static/index.html @@ -173,7 +173,7 @@
Loading...
- + @@ -264,22 +264,36 @@ // Input handler — filter out OSC responses (e.g. color query replies like \e]11;rgb:...\e\) // that xterm.js generates in response to shell/readline queries. Without filtering, // these leak into the PTY input buffer and corrupt commands. + this._inputQueue = ''; + this._inputFlushScheduled = false; this.term.onData(input => { if (!this.sessionId) return; // Drop OSC responses: \x1b] ... \x1b\ or \x1b] ... \x07 if (/\x1b\]/.test(input)) return; - // Use true WebSocket if available, else HTTP POST (always prefer HTTP - // over SocketIO long-polling — simpler and lower overhead) + // Use true WebSocket if available (instant, no batching needed) if (window._wsRealWebSocket && window._socket) { window._socket.emit('terminal_input', { session_id: this.sessionId, input: input }); - } else { - fetch('/api/input', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: this.sessionId, input: input }) - }).then(resp => resp.json()).then(data => { - if (data.output) this.writeOutput(data.output); - }).catch(err => console.warn('Input send failed:', err)); + return; + } + // HTTP path: batch keystrokes within one animation frame (~16ms) + // to reduce round trips through the Azure proxy (~340ms each) + this._inputQueue += input; + if (!this._inputFlushScheduled) { + this._inputFlushScheduled = true; + const pane = this; + requestAnimationFrame(() => { + const batch = pane._inputQueue; + pane._inputQueue = ''; + pane._inputFlushScheduled = false; + if (!batch || !pane.sessionId) return; + fetch('/api/input', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: pane.sessionId, input: batch }) + }).then(resp => resp.json()).then(data => { + if (data.output) pane.writeOutput(data.output); + }).catch(err => console.warn('Input send failed:', err)); + }); } }); } diff --git a/static/socket.io.min.js b/static/socket.io.min.js new file mode 100644 index 0000000..d6b2d60 --- /dev/null +++ b/static/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.7.5 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).io=t()}(this,(function(){"use strict";function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){a=!0,o=e},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var g=Object.create(null);Object.keys(v).forEach((function(e){g[v[e]]=e}));var m,b={type:"error",data:"parser error"},k="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),w="function"==typeof ArrayBuffer,_=function(e){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer instanceof ArrayBuffer},E=function(e,t,n){var r=e.type,i=e.data;return k&&i instanceof Blob?t?n(i):A(i,n):w&&(i instanceof ArrayBuffer||_(i))?t?n(i):A(new Blob([i]),n):n(v[r]+(i||""))},A=function(e,t){var n=new FileReader;return n.onload=function(){var e=n.result.split(",")[1];t("b"+(e||""))},n.readAsDataURL(e)};function O(e){return e instanceof Uint8Array?e:e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}for(var T="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),C=0;C<64;C++)R[T.charCodeAt(C)]=C;var B,S="function"==typeof ArrayBuffer,N=function(e,t){if("string"!=typeof e)return{type:"message",data:x(e,t)};var n=e.charAt(0);return"b"===n?{type:"message",data:L(e.substring(1),t)}:g[n]?e.length>1?{type:g[n],data:e.substring(1)}:{type:g[n]}:b},L=function(e,t){if(S){var n=function(e){var t,n,r,i,o,s=.75*e.length,a=e.length,c=0;"="===e[e.length-1]&&(s--,"="===e[e.length-2]&&s--);var u=new ArrayBuffer(s),h=new Uint8Array(u);for(t=0;t>4,h[c++]=(15&r)<<4|i>>2,h[c++]=(3&i)<<6|63&o;return u}(e);return x(n,t)}return{base64:!0,data:e}},x=function(e,t){return"blob"===t?e instanceof Blob?e:new Blob([e]):e instanceof ArrayBuffer?e:e.buffer},P=String.fromCharCode(30);function j(){return new TransformStream({transform:function(e,t){!function(e,t){k&&e.data instanceof Blob?e.data.arrayBuffer().then(O).then(t):w&&(e.data instanceof ArrayBuffer||_(e.data))?t(O(e.data)):E(e,!1,(function(e){m||(m=new TextEncoder),t(m.encode(e))}))}(e,(function(n){var r,i=n.length;if(i<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,i);else if(i<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,i)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(i))}e.data&&"string"!=typeof e.data&&(r[0]|=128),t.enqueue(r),t.enqueue(n)}))}})}function q(e){return e.reduce((function(e,t){return e+t.length}),0)}function D(e,t){if(e[0].length===t)return e.shift();for(var n=new Uint8Array(t),r=0,i=0;i1?t-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return e+"://"+this._hostname()+this._port()+this.opts.path+this._query(t)}},{key:"_hostname",value:function(){var e=this.opts.hostname;return-1===e.indexOf(":")?e:"["+e+"]"}},{key:"_port",value:function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}},{key:"_query",value:function(e){var t=function(e){var t="";for(var n in e)e.hasOwnProperty(n)&&(t.length&&(t+="&"),t+=encodeURIComponent(n)+"="+encodeURIComponent(e[n]));return t}(e);return t.length?"?"+t:""}}]),i}(U),z="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),J=64,$={},Q=0,X=0;function G(e){var t="";do{t=z[e%J]+t,e=Math.floor(e/J)}while(e>0);return t}function Z(){var e=G(+new Date);return e!==K?(Q=0,K=e):e+"."+G(Q++)}for(;X0&&void 0!==arguments[0]?arguments[0]:{};return i(e,{xd:this.xd,cookieJar:this.cookieJar},this.opts),new se(this.uri(),e)}},{key:"doWrite",value:function(e,t){var n=this,r=this.request({method:"POST",data:e});r.on("success",t),r.on("error",(function(e,t){n.onError("xhr post error",e,t)}))}},{key:"doPoll",value:function(){var e=this,t=this.request();t.on("data",this.onData.bind(this)),t.on("error",(function(t,n){e.onError("xhr poll error",t,n)})),this.pollXhr=t}}]),s}(W),se=function(e){o(i,e);var n=l(i);function i(e,r){var o;return t(this,i),H(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=e,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var e,t=this,n=F(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this.opts.xd;var r=this.xhr=new ne(n);try{r.open(this.method,this.uri,!0);try{if(this.opts.extraHeaders)for(var o in r.setDisableHeaderCheck&&r.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&r.setRequestHeader(o,this.opts.extraHeaders[o])}catch(e){}if("POST"===this.method)try{r.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(e){}try{r.setRequestHeader("Accept","*/*")}catch(e){}null===(e=this.opts.cookieJar)||void 0===e||e.addCookies(r),"withCredentials"in r&&(r.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(r.timeout=this.opts.requestTimeout),r.onreadystatechange=function(){var e;3===r.readyState&&(null===(e=t.opts.cookieJar)||void 0===e||e.parseCookies(r)),4===r.readyState&&(200===r.status||1223===r.status?t.onLoad():t.setTimeoutFn((function(){t.onError("number"==typeof r.status?r.status:0)}),0))},r.send(this.data)}catch(e){return void this.setTimeoutFn((function(){t.onError(e)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(e){this.emitReserved("error",e,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(e){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=re,e)try{this.xhr.abort()}catch(e){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var e=this.xhr.responseText;null!==e&&(this.emitReserved("data",e),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(U);if(se.requestsCount=0,se.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",ae);else if("function"==typeof addEventListener){addEventListener("onpagehide"in I?"pagehide":"unload",ae,!1)}function ae(){for(var e in se.requests)se.requests.hasOwnProperty(e)&&se.requests[e].abort()}var ce="function"==typeof Promise&&"function"==typeof Promise.resolve?function(e){return Promise.resolve().then(e)}:function(e,t){return t(e,0)},ue=I.WebSocket||I.MozWebSocket,he="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),fe=function(e){o(i,e);var n=l(i);function i(e){var r;return t(this,i),(r=n.call(this,e)).supportsBinary=!e.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var e=this.uri(),t=this.opts.protocols,n=he?{}:F(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=he?new ue(e,t,n):t?new ue(e,t):new ue(e)}catch(e){return this.emitReserved("error",e)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}}},{key:"addEventListeners",value:function(){var e=this;this.ws.onopen=function(){e.opts.autoUnref&&e.ws._socket.unref(),e.onOpen()},this.ws.onclose=function(t){return e.onClose({description:"websocket connection closed",context:t})},this.ws.onmessage=function(t){return e.onData(t.data)},this.ws.onerror=function(t){return e.onError("websocket error",t)}}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;E(n,t.supportsBinary,(function(e){try{t.ws.send(e)}catch(e){}i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){a.enqueue(b);break}i=l*Math.pow(2,32)+f.getUint32(4),r=3}else{if(q(n)e){a.enqueue(b);break}}}})}(Number.MAX_SAFE_INTEGER,e.socket.binaryType),r=t.readable.pipeThrough(n).getReader(),i=j();i.readable.pipeTo(t.writable),e.writer=i.writable.getWriter();!function t(){r.read().then((function(n){var r=n.done,i=n.value;r||(e.onPacket(i),t())})).catch((function(e){}))}();var o={type:"open"};e.query.sid&&(o.data='{"sid":"'.concat(e.query.sid,'"}')),e.writer.write(o).then((function(){return e.onOpen()}))}))})))}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;t.writer.write(n).then((function(){i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return t(this,a),(r=s.call(this)).binaryType="arraybuffer",r.writeBuffer=[],n&&"object"===e(n)&&(o=n,n=null),n?(n=ve(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=ve(o.host).host),H(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket","webtransport"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(e){for(var t={},n=e.split("&"),r=0,i=n.length;r1))return this.writeBuffer;for(var e,t=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(e):Math.ceil(1.33*(e.byteLength||e.size))),n>0&&t>this.maxPayload)return this.writeBuffer.slice(0,n);t+=2}return this.writeBuffer}},{key:"write",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"send",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"sendPacket",value:function(e,t,n,r){if("function"==typeof t&&(r=t,t=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:e,data:t,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var e=this,t=function(){e.onClose("forced close"),e.transport.close()},n=function n(){e.off("upgrade",n),e.off("upgradeError",n),t()},r=function(){e.once("upgrade",n),e.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){e.upgrading?r():t()})):this.upgrading?r():t()),this}},{key:"onError",value:function(e){a.priorWebsocketSuccess=!1,this.emitReserved("error",e),this.onClose("transport error",e)}},{key:"onClose",value:function(e,t){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",e,t),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(e){for(var t=[],n=0,r=e.length;n=0&&t.num1?t-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),t&&t(e));else if(n._queue.shift(),t){for(var i=arguments.length,o=new Array(i>1?i-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this._queue.length){var t=this._queue[0];t.pending&&!e||(t.pending=!0,t.tryCount++,this.flags=t.flags,this.emit.apply(this,t.args))}}},{key:"packet",value:function(e){e.nsp=this.nsp,this.io._packet(e)}},{key:"onopen",value:function(){var e=this;"function"==typeof this.auth?this.auth((function(t){e._sendConnectPacket(t)})):this._sendConnectPacket(this.auth)}},{key:"_sendConnectPacket",value:function(e){this.packet({type:Be.CONNECT,data:this._pid?i({pid:this._pid,offset:this._lastOffset},e):e})}},{key:"onerror",value:function(e){this.connected||this.emitReserved("connect_error",e)}},{key:"onclose",value:function(e,t){this.connected=!1,delete this.id,this.emitReserved("disconnect",e,t),this._clearAcks()}},{key:"_clearAcks",value:function(){var e=this;Object.keys(this.acks).forEach((function(t){if(!e.sendBuffer.some((function(e){return String(e.id)===t}))){var n=e.acks[t];delete e.acks[t],n.withError&&n.call(e,new Error("socket has been disconnected"))}}))}},{key:"onpacket",value:function(e){if(e.nsp===this.nsp)switch(e.type){case Be.CONNECT:e.data&&e.data.sid?this.onconnect(e.data.sid,e.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Be.EVENT:case Be.BINARY_EVENT:this.onevent(e);break;case Be.ACK:case Be.BINARY_ACK:this.onack(e);break;case Be.DISCONNECT:this.ondisconnect();break;case Be.CONNECT_ERROR:this.destroy();var t=new Error(e.data.message);t.data=e.data.data,this.emitReserved("connect_error",t)}}},{key:"onevent",value:function(e){var t=e.data||[];null!=e.id&&t.push(this.ack(e.id)),this.connected?this.emitEvent(t):this.receiveBuffer.push(Object.freeze(t))}},{key:"emitEvent",value:function(e){if(this._anyListeners&&this._anyListeners.length){var t,n=y(this._anyListeners.slice());try{for(n.s();!(t=n.n()).done;){t.value.apply(this,e)}}catch(e){n.e(e)}finally{n.f()}}p(s(a.prototype),"emit",this).apply(this,e),this._pid&&e.length&&"string"==typeof e[e.length-1]&&(this._lastOffset=e[e.length-1])}},{key:"ack",value:function(e){var t=this,n=!1;return function(){if(!n){n=!0;for(var r=arguments.length,i=new Array(r),o=0;o0&&e.jitter<=1?e.jitter:0,this.attempts=0}Ie.prototype.duration=function(){var e=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var t=Math.random(),n=Math.floor(t*this.jitter*e);e=0==(1&Math.floor(10*t))?e-n:e+n}return 0|Math.min(e,this.max)},Ie.prototype.reset=function(){this.attempts=0},Ie.prototype.setMin=function(e){this.ms=e},Ie.prototype.setMax=function(e){this.max=e},Ie.prototype.setJitter=function(e){this.jitter=e};var Fe=function(n){o(s,n);var i=l(s);function s(n,r){var o,a;t(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===e(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,H(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new Ie({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var c=r.parser||je;return o.encoder=new c.Encoder,o.decoder=new c.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(e){return arguments.length?(this._reconnection=!!e,this):this._reconnection}},{key:"reconnectionAttempts",value:function(e){return void 0===e?this._reconnectionAttempts:(this._reconnectionAttempts=e,this)}},{key:"reconnectionDelay",value:function(e){var t;return void 0===e?this._reconnectionDelay:(this._reconnectionDelay=e,null===(t=this.backoff)||void 0===t||t.setMin(e),this)}},{key:"randomizationFactor",value:function(e){var t;return void 0===e?this._randomizationFactor:(this._randomizationFactor=e,null===(t=this.backoff)||void 0===t||t.setJitter(e),this)}},{key:"reconnectionDelayMax",value:function(e){var t;return void 0===e?this._reconnectionDelayMax:(this._reconnectionDelayMax=e,null===(t=this.backoff)||void 0===t||t.setMax(e),this)}},{key:"timeout",value:function(e){return arguments.length?(this._timeout=e,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(e){var t=this;if(~this._readyState.indexOf("open"))return this;this.engine=new ge(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=qe(n,"open",(function(){r.onopen(),e&&e()})),o=function(n){t.cleanup(),t._readyState="closed",t.emitReserved("error",n),e?e(n):t.maybeReconnectOnOpen()},s=qe(n,"error",o);if(!1!==this._timeout){var a=this._timeout,c=this.setTimeoutFn((function(){i(),o(new Error("timeout")),n.close()}),a);this.opts.autoUnref&&c.unref(),this.subs.push((function(){t.clearTimeoutFn(c)}))}return this.subs.push(i),this.subs.push(s),this}},{key:"connect",value:function(e){return this.open(e)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var e=this.engine;this.subs.push(qe(e,"ping",this.onping.bind(this)),qe(e,"data",this.ondata.bind(this)),qe(e,"error",this.onerror.bind(this)),qe(e,"close",this.onclose.bind(this)),qe(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(e){try{this.decoder.add(e)}catch(e){this.onclose("parse error",e)}}},{key:"ondecoded",value:function(e){var t=this;ce((function(){t.emitReserved("packet",e)}),this.setTimeoutFn)}},{key:"onerror",value:function(e){this.emitReserved("error",e)}},{key:"socket",value:function(e,t){var n=this.nsps[e];return n?this._autoConnect&&!n.active&&n.connect():(n=new Ue(this,e,t),this.nsps[e]=n),n}},{key:"_destroy",value:function(e){for(var t=0,n=Object.keys(this.nsps);t=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){t.skipReconnect||(e.emitReserved("reconnect_attempt",t.backoff.attempts),t.skipReconnect||t.open((function(n){n?(t._reconnecting=!1,t.reconnect(),e.emitReserved("reconnect_error",n)):t.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){e.clearTimeoutFn(r)}))}}},{key:"onreconnect",value:function(){var e=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",e)}}]),s}(U),Me={};function Ve(t,n){"object"===e(t)&&(n=t,t=void 0);var r,i=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=e;n=n||"undefined"!=typeof location&&location,null==e&&(e=n.protocol+"//"+n.host),"string"==typeof e&&("/"===e.charAt(0)&&(e="/"===e.charAt(1)?n.protocol+e:n.host+e),/^(https?|wss?):\/\//.test(e)||(e=void 0!==n?n.protocol+"//"+e:"https://"+e),r=ve(e)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+t,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,c=Me[s]&&a in Me[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||c?r=new Fe(o,n):(Me[s]||(Me[s]=new Fe(o,n)),r=Me[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Ve,{Manager:Fe,Socket:Ue,io:Ve,connect:Ve}),Ve})); +//# sourceMappingURL=socket.io.min.js.map From e292b7406e418d8fab8ad250003d19b1ec96e55e Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 9 Mar 2026 22:01:26 +1100 Subject: [PATCH 33/39] fix: Prevent dual output delivery causing character duplication Both WebSocket and HTTP batch polling were delivering terminal output simultaneously, causing every keystroke to echo twice. Only start HTTP polling when WebSocket isn't active, and guard batchPoll() to skip when WebSocket is delivering output. Co-Authored-By: Claude Opus 4.6 --- static/index.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/static/index.html b/static/index.html index 196f42f..11bb44a 100644 --- a/static/index.html +++ b/static/index.html @@ -437,8 +437,10 @@ // Check for existing tmux sessions to restore await this.restoreOrCreate(); - // Start adaptive batch polling (serves as fallback when WebSocket is down) - this.startPolling(); + // Start adaptive batch polling only if WebSocket isn't already handling output + if (!window._wsRealWebSocket) { + this.startPolling(); + } this.updateIndicators(); } @@ -752,6 +754,9 @@ } async batchPoll() { + // Skip polling when WebSocket is delivering output (prevents duplication) + if (window._wsRealWebSocket) return; + const sessionIds = []; const paneMap = new Map(); // sessionId -> pane From 655353ec77bcf1df323c59c9707b7b4a766e1bb5 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Tue, 10 Mar 2026 12:52:10 +1100 Subject: [PATCH 34/39] feat: Add /api/active-sessions endpoint, gate tmux on TMUX_ENABLED Co-Authored-By: Claude Opus 4.6 --- app.py | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index dd91ab1..c8bc54e 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,10 @@ CLEANUP_INTERVAL_SECONDS = 30 # How often to check for stale sessions GRACEFUL_SHUTDOWN_WAIT = 3 # Seconds to wait after SIGHUP before SIGKILL +# Terminal mode configuration +TMUX_ENABLED = os.environ.get('TMUX_ENABLED', 'true').lower() == 'true' +TERMINAL_MODE = os.environ.get('TERMINAL_MODE', 'tabs') + # Logging setup logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -169,7 +173,10 @@ def _update_step(step_id, **kwargs): def _get_setup_state_snapshot(): with setup_lock: - return copy.deepcopy(setup_state) + snapshot = copy.deepcopy(setup_state) + snapshot['terminal_mode'] = TERMINAL_MODE + snapshot['tmux_enabled'] = TMUX_ENABLED + return snapshot # Single-user security: only the token owner can access the terminal @@ -762,8 +769,8 @@ def cleanup_stale_sessions(): @app.before_request def authorize_request(): """Check authorization before processing any request.""" - # Skip auth for health check and setup status - if request.path in ("/health", "/api/setup-status"): + # Skip auth for health check, setup status, and active sessions + if request.path in ("/health", "/api/setup-status", "/api/active-sessions"): return None authorized, user = check_authorization() @@ -851,6 +858,32 @@ def list_tmux_sessions(): return jsonify({"sessions": []}) +@app.route("/api/active-sessions") +def list_active_sessions(): + """List active PTY sessions for reconnection (non-tmux mode). + + Returns: {"sessions": [{"session_id": "...", "pane_id": N, "alive": bool}, ...]} + Filters out sessions whose process has exited. + """ + result = [] + with sessions_lock: + for session_id, session in sessions.items(): + pid = session.get("pid") + alive = False + if pid is not None: + try: + os.kill(pid, 0) # Check if process is still running + alive = not session.get("exited", False) + except OSError: + alive = False + result.append({ + "session_id": session_id, + "pane_id": session.get("pane_id", 0), + "alive": alive, + }) + return jsonify({"sessions": result}) + + @app.route("/api/session", methods=["POST"]) def create_session(): """Create a new terminal session.""" @@ -860,7 +893,7 @@ def create_session(): return jsonify({"error": "Maximum session limit reached"}), 503 try: - data = request.json or {} + data = request.get_json(silent=True) or {} pane_id = int(data.get("pane_id", 0)) master_fd, slave_fd = pty.openpty() @@ -892,9 +925,11 @@ def create_session(): # Use tmux for session persistence across page refreshes. # tmux new-session -A: attach if session exists, create if not. + # Re-read TMUX_ENABLED at request time so tests can toggle it via env. + tmux_enabled_now = os.environ.get('TMUX_ENABLED', 'true').lower() == 'true' tmux_session = f"pane-{pane_id}" reattached = False - if shutil.which("tmux"): + if tmux_enabled_now and shutil.which("tmux"): # Check if this tmux session already exists (reattach vs new) check = subprocess.run( ["tmux", "has-session", "-t", tmux_session], @@ -922,6 +957,7 @@ def create_session(): sessions[session_id] = { "master_fd": master_fd, "pid": pid, + "pane_id": pane_id, "output_buffer": deque(maxlen=1000), "last_poll_time": time.time(), "created_at": time.time(), From 8bcc2fa7d6114dee459228233026b1bf768940c1 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Tue, 10 Mar 2026 15:20:02 +1100 Subject: [PATCH 35/39] feat: Add TabManager class, tab bar, mode toggle to terminal UI Co-Authored-By: Claude Opus 4.6 --- static/index.html | 435 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 1 deletion(-) diff --git a/static/index.html b/static/index.html index 11bb44a..0d1c21c 100644 --- a/static/index.html +++ b/static/index.html @@ -145,6 +145,84 @@ } .add-pane-btn:hover { background: #444; color: #ccc; } + /* Tab bar */ + #tab-bar { + display: flex; + align-items: center; + gap: 0; + padding: 0 4px; + background: #181818; + border-bottom: 1px solid #333; + height: 32px; + flex-shrink: 0; + overflow-x: auto; + } + #tab-bar .tab-btn { + display: flex; + align-items: center; + gap: 6px; + background: #252525; + color: #aaa; + border: 1px solid #333; + border-bottom: none; + border-radius: 4px 4px 0 0; + padding: 4px 12px; + font-family: monospace; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + min-width: 100px; + } + #tab-bar .tab-btn:hover { background: #333; } + #tab-bar .tab-btn.active { background: #1e1e1e; color: #fff; border-color: #555; } + #tab-bar .tab-btn .close-tab { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 12px; + padding: 0 2px; + line-height: 1; + } + #tab-bar .tab-btn .close-tab:hover { color: #ff5555; } + #tab-bar .add-tab-btn { + background: none; + border: 1px solid #444; + border-bottom: none; + border-radius: 4px 4px 0 0; + color: #888; + cursor: pointer; + font-size: 16px; + padding: 2px 10px; + margin-left: 2px; + } + #tab-bar .add-tab-btn:hover { color: #ccc; background: #333; } + + /* Mode toggle */ + .mode-toggle { + background: #333; + color: #aaa; + border: 1px solid #555; + border-radius: 3px; + padding: 2px 10px; + font-family: monospace; + font-size: 11px; + cursor: pointer; + margin-left: auto; + } + .mode-toggle:hover { background: #444; color: #ccc; } + + /* Tab terminal container */ + #tab-terminal-container { + width: 100vw; + height: calc(100vh - 68px); + position: relative; + } + #tab-terminal-container .tab-pane-slot { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + } + /* Status overlay */ #status { position: absolute; @@ -169,7 +247,10 @@
Ctrl+Shift+N: cycle focus + +
+
Loading...
@@ -817,6 +898,332 @@ } } + /* ===== TabManager: tab-based terminal UI (alternative to LayoutManager grid) ===== */ + class TabManager { + constructor() { + this.tabs = new Map(); // tabId -> { pane: TerminalPane, element: DOM } + this.activeTab = null; + this.nextTabId = 1; + this.pollTimer = null; + this.pollInterval = POLL_FOCUSED; + this.tabBar = document.getElementById('tab-bar'); + this.container = document.getElementById('tab-terminal-container'); + } + + async init() { + // Add the "+" button to the tab bar + this.addTabBtn = document.createElement('button'); + this.addTabBtn.className = 'add-tab-btn'; + this.addTabBtn.textContent = '+'; + this.addTabBtn.title = 'New tab'; + this.addTabBtn.addEventListener('click', () => this.addTab()); + this.tabBar.appendChild(this.addTabBtn); + + // Keyboard shortcut: Ctrl+Shift+N to cycle tabs + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && (e.key === 'N' || e.key === 'n')) { + e.preventDefault(); + if (currentMode === 'tabs') { + this.nextTab(); + } else { + layoutManager.cycleFocus(); + } + } + }); + + // Debounced resize handler + let resizeTimeout = null; + window.addEventListener('resize', () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => this.handleResize(), 200); + }); + + // Adaptive polling: slow down when browser tab is hidden + document.addEventListener('visibilitychange', () => { + this.updatePollRate(); + }); + + // Cleanup on page unload + window.addEventListener('beforeunload', () => this.cleanup()); + + // Check for existing sessions to restore + await this.restoreOrCreate(); + + // Start polling if WebSocket not active + if (!window._wsRealWebSocket) { + this.startPolling(); + } + } + + async restoreOrCreate() { + // Try to restore existing non-tmux sessions via /api/active-sessions + let existingSessions = []; + try { + const resp = await fetch('/api/active-sessions'); + const data = await resp.json(); + existingSessions = (data.sessions || []).filter(s => s.alive); + } catch (e) { /* ignore */ } + + if (existingSessions.length > 0) { + // Restore existing sessions as tabs + for (const s of existingSessions) { + await this.addTab(s.pane_id); + } + } else { + // Fresh start: one tab + await this.addTab(); + } + } + + async addTab(paneId) { + const tabId = this.nextTabId++; + if (paneId === undefined) paneId = tabId - 1; + + // Create terminal pane + const pane = new TerminalPane(paneId); + + // Create tab pane container (for CSS show/hide) + const tabPaneSlot = document.createElement('div'); + tabPaneSlot.className = 'tab-pane-slot'; + tabPaneSlot.dataset.tabId = tabId; + tabPaneSlot.style.display = 'none'; + this.container.appendChild(tabPaneSlot); + + // Initialize pane into the slot + await pane.init(tabPaneSlot); + + // Create tab button in tab bar + const tabBtn = document.createElement('div'); + tabBtn.className = 'tab-btn'; + tabBtn.dataset.tabId = tabId; + tabBtn.innerHTML = `Terminal ${tabId}`; + + // Close button on tab + const closeTabBtn = document.createElement('button'); + closeTabBtn.className = 'close-tab'; + closeTabBtn.textContent = 'X'; + closeTabBtn.title = 'Close tab'; + closeTabBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.closeTab(tabId); + }); + tabBtn.appendChild(closeTabBtn); + + tabBtn.addEventListener('click', () => this.switchTab(tabId)); + + // Insert before the "+" button + this.tabBar.insertBefore(tabBtn, this.addTabBtn); + + // Store tab data + this.tabs.set(tabId, { pane, element: tabPaneSlot, button: tabBtn }); + + // Activate this new tab + this.switchTab(tabId); + + return tabId; + } + + switchTab(tabId) { + // Hide all tab panes, show the active one + for (const [id, tab] of this.tabs) { + if (id === tabId) { + tab.element.style.display = 'block'; + tab.button.classList.add('active'); + tab.pane.fit(); + tab.pane.focus(); + } else { + tab.element.style.display = 'none'; + tab.button.classList.remove('active'); + } + } + this.activeTab = tabId; + } + + nextTab() { + const tabIds = Array.from(this.tabs.keys()).sort((a, b) => a - b); + if (tabIds.length <= 1) return; + const currentIdx = tabIds.indexOf(this.activeTab); + const nextIdx = (currentIdx + 1) % tabIds.length; + this.switchTab(tabIds[nextIdx]); + } + + async closeTab(tabId) { + const tab = this.tabs.get(tabId); + if (!tab) return; + + // Destroy the pane (calls /api/session/close) + await tab.pane.destroy(); + tab.element.remove(); + tab.button.remove(); + this.tabs.delete(tabId); + + // If last tab was closed, auto-create a new one + if (this.tabs.size === 0) { + await this.addTab(); + return; + } + + // Switch to another tab if the closed one was active + if (this.activeTab === tabId) { + const remainingIds = Array.from(this.tabs.keys()); + this.switchTab(remainingIds[0]); + } + } + + getActiveSessionId() { + if (!this.activeTab) return null; + const tab = this.tabs.get(this.activeTab); + return tab && tab.pane ? tab.pane.sessionId : null; + } + + startPolling() { + if (this.pollTimer) clearInterval(this.pollTimer); + this.pollTimer = setInterval(() => this.batchPoll(), this.pollInterval); + } + + updatePollRate() { + const newInterval = document.hidden ? POLL_HIDDEN : POLL_FOCUSED; + if (newInterval !== this.pollInterval) { + this.pollInterval = newInterval; + this.startPolling(); + } + } + + async batchPoll() { + // Skip polling when WebSocket is delivering output + if (window._wsRealWebSocket) return; + + // In tab mode, only poll the activeSession to save bandwidth + const activeSessionId = this.getActiveSessionId(); + const sessionIds = []; + const paneMap = new Map(); + + if (activeSessionId) { + sessionIds.push(activeSessionId); + const tab = this.tabs.get(this.activeTab); + if (tab && tab.pane) paneMap.set(activeSessionId, tab.pane); + } + + if (sessionIds.length === 0) return; + + try { + const resp = await fetch('/api/output-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_ids: sessionIds }) + }); + if (!resp.ok) return; + const data = await resp.json(); + + for (const [sid, result] of Object.entries(data.outputs || {})) { + const pane = paneMap.get(sid); + if (!pane) continue; + if (result.output) pane.writeOutput(result.output); + if (result.exited) pane.markExited(); + } + } catch (e) { + console.error('Tab batch poll error:', e); + } + } + + handleResize() { + for (const [id, tab] of this.tabs) { + if (tab.pane) { + tab.pane.fit(); + tab.pane.sendResize(); + } + } + } + + cleanup() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + for (const [id, tab] of this.tabs) { + if (tab.pane) tab.pane.destroy(); + } + this.tabs.clear(); + } + + // Transfer existing panes from grid LayoutManager (preserve sessions during mode switch) + migrateFromLayout(existingPanes) { + // Preserve and transfer panes without destroying sessions + for (const [idx, pane] of existingPanes) { + if (!pane || !pane.sessionId) continue; + const tabId = this.nextTabId++; + + const tabPaneSlot = document.createElement('div'); + tabPaneSlot.className = 'tab-pane-slot'; + tabPaneSlot.dataset.tabId = tabId; + tabPaneSlot.style.display = 'none'; + this.container.appendChild(tabPaneSlot); + + // Reparent pane element + if (pane.element && pane.element.parentNode) { + pane.element.parentNode.removeChild(pane.element); + } + tabPaneSlot.appendChild(pane.element); + + // Create tab button + const tabBtn = document.createElement('div'); + tabBtn.className = 'tab-btn'; + tabBtn.dataset.tabId = tabId; + tabBtn.innerHTML = `Terminal ${tabId}`; + const closeTabBtn = document.createElement('button'); + closeTabBtn.className = 'close-tab'; + closeTabBtn.textContent = 'X'; + closeTabBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.closeTab(tabId); + }); + tabBtn.appendChild(closeTabBtn); + tabBtn.addEventListener('click', () => this.switchTab(tabId)); + this.tabBar.insertBefore(tabBtn, this.addTabBtn); + + this.tabs.set(tabId, { pane, element: tabPaneSlot, button: tabBtn }); + } + if (this.tabs.size > 0) { + this.switchTab(Array.from(this.tabs.keys())[0]); + } + } + } + + /* ===== Mode management: tabs vs grid ===== */ + let currentMode = 'tabs'; // default terminal-mode is 'tabs' + let tabManager = null; + + function toggleMode() { + const newMode = currentMode === 'tabs' ? 'grid' : 'tabs'; + setMode(newMode); + } + + function setMode(mode) { + currentMode = mode; + localStorage.setItem('terminal-mode', mode); + const modeBtn = document.getElementById('mode-toggle'); + + if (mode === 'tabs') { + modeBtn.textContent = 'Tabs'; + // Show tab UI, hide grid UI + document.getElementById('tab-bar').style.display = 'flex'; + document.getElementById('tab-terminal-container').style.display = 'block'; + document.getElementById('pane-container').style.display = 'none'; + document.getElementById('toolbar').style.display = 'none'; + // Migrate existing panes from grid to tabs to preserve sessions + if (tabManager && layoutManager.panes.size > 0 && tabManager.tabs.size === 0) { + tabManager.migrateFromLayout(layoutManager.panes); + } + } else { + modeBtn.textContent = 'Grid'; + // Show grid UI, hide tab UI + document.getElementById('tab-bar').style.display = 'none'; + document.getElementById('tab-terminal-container').style.display = 'none'; + document.getElementById('pane-container').style.display = 'grid'; + document.getElementById('toolbar').style.display = 'flex'; + } + } + /* ===== Toast Notification ===== */ function showToast(message, type = 'info') { const toast = document.createElement('div'); @@ -925,7 +1332,33 @@ if (typeof Terminal === 'undefined') throw new Error('xterm.js not loaded'); if (typeof FitAddon === 'undefined') throw new Error('FitAddon not loaded'); - await layoutManager.init(); + // Determine terminal mode: localStorage overrides server config + let serverTerminalMode = 'tabs'; + try { + const setupResp = await fetch('/api/setup-status'); + const setupData = await setupResp.json(); + if (setupData.terminal_mode) { + serverTerminalMode = setupData.terminal_mode; + } + } catch (e) { /* ignore */ } + + // localStorage takes priority over server config (terminalMode override) + const localMode = localStorage.getItem('terminal-mode'); + const terminalMode = localMode || serverTerminalMode; + + // Set up mode toggle button + document.getElementById('mode-toggle').addEventListener('click', toggleMode); + + // Initialize TabManager + tabManager = new TabManager(); + + if (terminalMode === 'tabs') { + await tabManager.init(); + setMode('tabs'); + } else { + await layoutManager.init(); + setMode('grid'); + } status.textContent = 'Connected!'; setTimeout(() => { status.style.display = 'none'; }, 1000); From e55cda5d24cff296c456176d0cbd9a84b17be818 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Tue, 10 Mar 2026 15:20:16 +1100 Subject: [PATCH 36/39] feat: Add TERMINAL_MODE and TMUX_ENABLED to app.yaml.template Co-Authored-By: Claude Opus 4.6 --- app.yaml.template | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app.yaml.template b/app.yaml.template index b5d81bc..ba1475d 100644 --- a/app.yaml.template +++ b/app.yaml.template @@ -38,3 +38,11 @@ env: # Default: true (set to "false" to disable) # - name: STATE_SYNC # value: "true" + #OPTIONAL: Terminal UI mode. "tabs" (default) shows browser-like tabs; "grid" shows + # the multi-pane grid layout. Users can toggle between modes in the UI. + # - name: TERMINAL_MODE + # value: "tabs" + #OPTIONAL: Enable tmux session persistence. When true (default), terminal sessions + # survive page refreshes via tmux. Set to "false" to use plain PTY sessions. + # - name: TMUX_ENABLED + # value: "true" From f75c624e18a61a0841a29234c302dca2c9f0f87a Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Tue, 10 Mar 2026 15:21:18 +1100 Subject: [PATCH 37/39] feat: Add test_tab_ui.py and test_active_sessions.py Co-Authored-By: Claude Opus 4.6 --- tests/test_active_sessions.py | 106 +++++++++++++++++++++++++++++++ tests/test_tab_ui.py | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 tests/test_active_sessions.py create mode 100644 tests/test_tab_ui.py diff --git a/tests/test_active_sessions.py b/tests/test_active_sessions.py new file mode 100644 index 0000000..99e946a --- /dev/null +++ b/tests/test_active_sessions.py @@ -0,0 +1,106 @@ +"""Tests for the /api/active-sessions endpoint. + +Validates that the endpoint exists, returns the correct response shape, +and correctly reports session status. +""" + +import os +import pytest + + +class TestActiveSessionsEndpoint: + """GET /api/active-sessions returns live PTY sessions.""" + + def test_endpoint_returns_200(self, app_client): + """GET /api/active-sessions returns 200.""" + resp = app_client.get("/api/active-sessions") + assert resp.status_code == 200 + + def test_response_has_sessions_key(self, app_client): + """Response JSON has 'sessions' key.""" + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + assert "sessions" in data + assert isinstance(data["sessions"], list) + + def test_empty_when_no_sessions(self, app_client): + """Returns empty list when no sessions exist.""" + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + # May have leftover sessions from other tests, but structure is valid + assert isinstance(data["sessions"], list) + + +class TestActiveSessionsWithSession: + """Tests requiring a live PTY session.""" + + def test_created_session_appears(self, app_client, create_session): + """A created session appears in /api/active-sessions.""" + original = os.environ.get("TMUX_ENABLED") + os.environ["TMUX_ENABLED"] = "false" + try: + session_id = create_session() + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + session_ids = [s["session_id"] for s in data["sessions"]] + assert session_id in session_ids + finally: + if original is None: + os.environ.pop("TMUX_ENABLED", None) + else: + os.environ["TMUX_ENABLED"] = original + + def test_session_has_required_fields(self, app_client, create_session): + """Each session has session_id, pane_id, and alive fields.""" + original = os.environ.get("TMUX_ENABLED") + os.environ["TMUX_ENABLED"] = "false" + try: + session_id = create_session() + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + matching = [s for s in data["sessions"] if s["session_id"] == session_id] + assert len(matching) == 1 + session = matching[0] + assert "session_id" in session + assert "pane_id" in session + assert "alive" in session + finally: + if original is None: + os.environ.pop("TMUX_ENABLED", None) + else: + os.environ["TMUX_ENABLED"] = original + + def test_alive_session_marked_true(self, app_client, create_session): + """A freshly created session is marked alive=True.""" + original = os.environ.get("TMUX_ENABLED") + os.environ["TMUX_ENABLED"] = "false" + try: + session_id = create_session() + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + matching = [s for s in data["sessions"] if s["session_id"] == session_id] + assert len(matching) == 1 + assert matching[0]["alive"] is True + finally: + if original is None: + os.environ.pop("TMUX_ENABLED", None) + else: + os.environ["TMUX_ENABLED"] = original + + def test_closed_session_not_alive(self, app_client, create_session): + """A closed session is either removed or marked alive=False.""" + original = os.environ.get("TMUX_ENABLED") + os.environ["TMUX_ENABLED"] = "false" + try: + session_id = create_session() + app_client.post("/api/session/close", json={"session_id": session_id}) + resp = app_client.get("/api/active-sessions") + data = resp.get_json() + matching = [s for s in data["sessions"] if s["session_id"] == session_id] + if matching: + assert matching[0]["alive"] is False + finally: + if original is None: + os.environ.pop("TMUX_ENABLED", None) + else: + os.environ["TMUX_ENABLED"] = original diff --git a/tests/test_tab_ui.py b/tests/test_tab_ui.py new file mode 100644 index 0000000..300af4e --- /dev/null +++ b/tests/test_tab_ui.py @@ -0,0 +1,113 @@ +"""Tests for the tab-based terminal UI in index.html. + +Validates that the TabManager class, tab bar, mode toggle, and related +tab UI structures exist and are correctly implemented. +""" + +import os +import re +import pytest + +INDEX_HTML_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "index.html" +) + + +@pytest.fixture +def html_source(): + """Read the index.html file.""" + with open(INDEX_HTML_PATH, "r") as f: + return f.read() + + +class TestTabBarStructure: + """Tab bar HTML structure.""" + + def test_tab_bar_element_exists(self, html_source): + assert 'id="tab-bar"' in html_source, "No tab-bar element found" + + def test_tab_terminal_container_exists(self, html_source): + assert "tab-terminal-container" in html_source, "No tab terminal container found" + + def test_add_tab_button_in_tab_bar(self, html_source): + assert "add-tab-btn" in html_source, "No add-tab button class found" + + def test_close_tab_button(self, html_source): + assert "close-tab" in html_source, "No close-tab button class found" + + +class TestTabManagerClass: + """TabManager JavaScript class.""" + + def test_tab_manager_class_exists(self, html_source): + assert "class TabManager" in html_source, "No TabManager class found" + + def test_add_tab_method(self, html_source): + assert "addTab" in html_source, "No addTab method found" + + def test_switch_tab_method(self, html_source): + assert "switchTab" in html_source, "No switchTab method found" + + def test_close_tab_method(self, html_source): + assert "closeTab" in html_source, "No closeTab method found" + + def test_next_tab_method(self, html_source): + assert "nextTab" in html_source, "No nextTab method found" + + def test_switch_tab_uses_display_css(self, html_source): + """switchTab uses display:block/none for visibility.""" + assert "display" in html_source.lower(), "No display CSS logic" + assert "none" in html_source, "No 'none' display value" + assert "block" in html_source, "No 'block' display value" + + def test_switch_tab_no_dispose(self, html_source): + """switchTab does not call .dispose() on hidden panes.""" + switch_pattern = re.compile( + r'switchTab\s*\([^)]*\)\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}', + re.DOTALL, + ) + match = switch_pattern.search(html_source) + assert match, "Could not find switchTab method body" + assert ".dispose()" not in match.group(1), "switchTab calls .dispose()" + + def test_last_tab_auto_creates(self, html_source): + """Closing last tab auto-creates a new one.""" + assert "this.tabs.size === 0" in html_source or "tabs.size === 0" in html_source, ( + "No auto-create logic for last tab" + ) + + +class TestModeToggle: + """Mode toggle between tabs and grid.""" + + def test_mode_toggle_element_exists(self, html_source): + assert "mode-toggle" in html_source, "No mode toggle element" + + def test_toggle_mode_function(self, html_source): + assert "toggleMode" in html_source, "No toggleMode function" + + def test_set_mode_function(self, html_source): + assert "setMode" in html_source, "No setMode function" + + def test_saves_to_localstorage(self, html_source): + assert "localStorage.setItem" in html_source, "No localStorage.setItem" + assert "terminal-mode" in html_source, "No terminal-mode key" + + def test_reads_from_localstorage(self, html_source): + assert "localStorage.getItem" in html_source, "No localStorage.getItem" + + def test_default_mode_is_tabs(self, html_source): + """Default mode is 'tabs'.""" + assert "'tabs'" in html_source or '"tabs"' in html_source, "No tabs default" + + +class TestPollingInTabMode: + """Batch polling optimization for tab mode.""" + + def test_tab_manager_has_batch_poll(self, html_source): + assert "batchPoll" in html_source, "No batchPoll in TabManager" + + def test_tab_mode_filters_to_active_session(self, html_source): + assert "activeSession" in html_source or "getActiveSessionId" in html_source, ( + "No active session filtering in tab mode polling" + ) From 74c795ec7677156147d2e70f9e34666e0847c94f Mon Sep 17 00:00:00 2001 From: David O'Keeffe Date: Tue, 10 Mar 2026 15:54:48 +1100 Subject: [PATCH 38/39] fix: Move mode toggle button outside toolbar for tabs-mode visibility Co-Authored-By: Claude Opus 4.6 --- static/index.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/static/index.html b/static/index.html index 0d1c21c..4761fdb 100644 --- a/static/index.html +++ b/static/index.html @@ -198,8 +198,12 @@ } #tab-bar .add-tab-btn:hover { color: #ccc; background: #333; } - /* Mode toggle */ + /* Mode toggle - floats top-right, always visible */ .mode-toggle { + position: fixed; + top: 6px; + right: 12px; + z-index: 100; background: #333; color: #aaa; border: 1px solid #555; @@ -208,7 +212,6 @@ font-family: monospace; font-size: 11px; cursor: pointer; - margin-left: auto; } .mode-toggle:hover { background: #444; color: #ccc; } @@ -247,8 +250,8 @@
Ctrl+Shift+N: cycle focus - +
From 2c007531928332b5ff850de08472ff1e3e7f343d Mon Sep 17 00:00:00 2001 From: David O'Keeffe Date: Tue, 10 Mar 2026 19:29:13 +1100 Subject: [PATCH 39/39] feat: Default TMUX_ENABLED to false for lighter-weight sessions Co-Authored-By: Claude Opus 4.6 --- app.py | 4 ++-- app.yaml.template | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index c8bc54e..6363f49 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ GRACEFUL_SHUTDOWN_WAIT = 3 # Seconds to wait after SIGHUP before SIGKILL # Terminal mode configuration -TMUX_ENABLED = os.environ.get('TMUX_ENABLED', 'true').lower() == 'true' +TMUX_ENABLED = os.environ.get('TMUX_ENABLED', 'false').lower() == 'true' TERMINAL_MODE = os.environ.get('TERMINAL_MODE', 'tabs') # Logging setup @@ -926,7 +926,7 @@ def create_session(): # Use tmux for session persistence across page refreshes. # tmux new-session -A: attach if session exists, create if not. # Re-read TMUX_ENABLED at request time so tests can toggle it via env. - tmux_enabled_now = os.environ.get('TMUX_ENABLED', 'true').lower() == 'true' + tmux_enabled_now = os.environ.get('TMUX_ENABLED', 'false').lower() == 'true' tmux_session = f"pane-{pane_id}" reattached = False if tmux_enabled_now and shutil.which("tmux"): diff --git a/app.yaml.template b/app.yaml.template index ba1475d..e9417c3 100644 --- a/app.yaml.template +++ b/app.yaml.template @@ -42,7 +42,7 @@ env: # the multi-pane grid layout. Users can toggle between modes in the UI. # - name: TERMINAL_MODE # value: "tabs" - #OPTIONAL: Enable tmux session persistence. When true (default), terminal sessions - # survive page refreshes via tmux. Set to "false" to use plain PTY sessions. + #OPTIONAL: Enable tmux session persistence. When "true", terminal sessions + # survive page refreshes via tmux. Default: "false" (plain PTY, lighter weight). # - name: TMUX_ENABLED - # value: "true" + # value: "false"