Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 57 additions & 26 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@

from utils import ensure_https

# Sanitize DATABRICKS_TOKEN early — the platform sometimes injects trailing
# newlines / whitespace which causes auth failures. Cleaning it here prevents
# the agent from "fixing" it in the terminal and leaking the raw token.
_raw_token = os.environ.get("DATABRICKS_TOKEN", "")
if _raw_token != _raw_token.strip():
os.environ["DATABRICKS_TOKEN"] = _raw_token.strip()

# App version (single source of truth: pyproject.toml)
_pyproject_file = os.path.join(os.path.dirname(__file__), 'pyproject.toml')
try:
Expand Down Expand Up @@ -83,6 +90,8 @@ def handle_sigterm(signum, frame):
"steps": [
{"id": "git", "label": "Configuring git identity", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "micro", "label": "Installing micro editor", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "gh", "label": "Installing GitHub CLI", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "dbcli", "label": "Upgrading Databricks CLI", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "proxy", "label": "Starting content-filter proxy", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "claude", "label": "Configuring Claude CLI", "status": "pending", "started_at": None, "completed_at": None, "error": None},
{"id": "codex", "label": "Configuring Codex CLI", "status": "pending", "started_at": None, "completed_at": None, "error": None},
Expand Down Expand Up @@ -117,6 +126,11 @@ def _run_step(step_id, command):
env = os.environ.copy()
if not env.get("HOME") or env["HOME"] == "/":
env["HOME"] = "/app/python/source_code"
home = env.get("HOME", "/app/python/source_code")
# Ensure uv and other tools in ~/.local/bin are on PATH
local_bin = os.path.join(home, ".local", "bin")
if local_bin not in env.get("PATH", ""):
env["PATH"] = f"{local_bin}:{env.get('PATH', '')}"
env.pop("DATABRICKS_CLIENT_ID", None)
env.pop("DATABRICKS_CLIENT_SECRET", None)

Expand Down Expand Up @@ -198,14 +212,14 @@ def _setup_git_config():
f.write('\n')
f.write('echo "[post-commit] $(date +%H:%M:%S) syncing $REPO_ROOT" >> "$SYNC_LOG"\n')
f.write('\n')
f.write('# Use venv python directly (avoids fragile source activate)\n')
f.write('VENV_PYTHON="/app/python/source_code/.venv/bin/python"\n')
f.write('SYNC_SCRIPT="/app/python/source_code/sync_to_workspace.py"\n')
f.write('# Use uv run so sync script gets the correct Python + deps\n')
f.write('APP_DIR="/app/python/source_code"\n')
f.write('SYNC_SCRIPT="$APP_DIR/sync_to_workspace.py"\n')
f.write('\n')
f.write('if [ -x "$VENV_PYTHON" ] && [ -f "$SYNC_SCRIPT" ]; then\n')
f.write(' nohup "$VENV_PYTHON" "$SYNC_SCRIPT" "$REPO_ROOT" >> "$SYNC_LOG" 2>&1 & disown\n')
f.write('if [ -f "$SYNC_SCRIPT" ]; then\n')
f.write(' nohup uv run --project "$APP_DIR" python "$SYNC_SCRIPT" "$REPO_ROOT" >> "$SYNC_LOG" 2>&1 & disown\n')
f.write('else\n')
f.write(' echo "[post-commit] $(date +%H:%M:%S) SKIP: venv=$VENV_PYTHON script=$SYNC_SCRIPT" >> "$SYNC_LOG"\n')
f.write(' echo "[post-commit] $(date +%H:%M:%S) SKIP: sync script not found" >> "$SYNC_LOG"\n')
f.write('fi\n')
os.chmod(post_commit, 0o755)
logger.info(f"Post-commit hook written to {post_commit}")
Expand Down Expand Up @@ -252,19 +266,24 @@ 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("gh", ["bash", "install_gh.sh"])

# --- Upgrade Databricks CLI (runtime image ships an older version) ---
_run_step("dbcli", ["bash", "install_databricks_cli.sh"])

# --- Content-filter proxy (must be running before OpenCode starts) ---
# Sanitizes requests/responses between OpenCode and Databricks
# (see OpenCode #5028, docs/plans/2026-03-11-litellm-empty-content-blocks-design.md)
_run_step("proxy", ["python", "setup_proxy.py"])
_run_step("proxy", ["uv", "run", "python", "setup_proxy.py"])

# --- Parallel agent setup (all independent of each other) ---
parallel_steps = [
("claude", ["python", "setup_claude.py"]),
("codex", ["python", "setup_codex.py"]),
("opencode", ["python", "setup_opencode.py"]),
("gemini", ["python", "setup_gemini.py"]),
("databricks", ["python", "setup_databricks.py"]),
("mlflow", ["python", "setup_mlflow.py"]),
("claude", ["uv", "run", "python", "setup_claude.py"]),
("codex", ["uv", "run", "python", "setup_codex.py"]),
("opencode", ["uv", "run", "python", "setup_opencode.py"]),
("gemini", ["uv", "run", "python", "setup_gemini.py"]),
("databricks", ["uv", "run", "python", "setup_databricks.py"]),
("mlflow", ["uv", "run", "python", "setup_mlflow.py"]),
]

with ThreadPoolExecutor(max_workers=len(parallel_steps)) as executor:
Expand Down Expand Up @@ -487,7 +506,7 @@ def read_pty_output(session_id, fd):
if session_id not in sessions:
break
try:
readable, _, errors = select.select([fd], [], [fd], 0.5)
readable, _, errors = select.select([fd], [], [fd], 0.05)
if readable or errors:
output = os.read(fd, 4096)
if not output:
Expand Down Expand Up @@ -569,7 +588,10 @@ def cleanup_stale_sessions():
warning_threshold = SESSION_TIMEOUT_SECONDS * 0.8

with sessions_lock:
for session_id, session in sessions.items():
session_snapshot = list(sessions.items())

for session_id, session in session_snapshot:
with session["lock"]:
idle = now - session["last_poll_time"]
if idle > SESSION_TIMEOUT_SECONDS:
stale_sessions.append((session_id, session["pid"], session["master_fd"]))
Expand Down Expand Up @@ -801,22 +823,31 @@ def get_output_batch():
outputs = {}
now = time.time()

# Step 1: Resolve session refs under global lock (fast dict lookups only)
resolved = {}
with sessions_lock:
for sid in session_ids:
if sid not in sessions:
continue
session = sessions[sid]
if sid in sessions:
resolved[sid] = sessions[sid]

# Step 2: Swap buffers under per-session locks (same pattern as get_output)
swapped = {}
for sid, session in resolved.items():
with session["lock"]:
session["last_poll_time"] = now
buffer = session["output_buffer"]
output = "".join(buffer)
buffer.clear()
old_buffer = session["output_buffer"]
session["output_buffer"] = deque(maxlen=1000)
exited = session.get("exited", False)
timeout_warning = session.pop("timeout_warning", False)
outputs[sid] = {
"output": output,
"exited": exited,
"timeout_warning": timeout_warning
}
swapped[sid] = (old_buffer, exited, timeout_warning)

# Step 3: Join strings outside all locks
for sid, (old_buffer, exited, timeout_warning) in swapped.items():
outputs[sid] = {
"output": "".join(old_buffer),
"exited": exited,
"timeout_warning": timeout_warning,
}

return jsonify({"outputs": outputs, "shutting_down": shutting_down})

Expand Down
26 changes: 26 additions & 0 deletions install_databricks_cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# Install the latest Databricks CLI to ~/.local/bin.
#
# - Fetches the latest release tag from the GitHub API
# - Downloads and unzips the Linux amd64 binary
# - Prints the installed version

set -euo pipefail

INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"

# Fetch latest release tag
DB_CLI_VERSION=$(curl -fsSL "https://api.github.com/repos/databricks/cli/releases/latest" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['tag_name'].lstrip('v'))")

echo "Installing Databricks CLI v${DB_CLI_VERSION}"

curl -fsSL "https://github.com/databricks/cli/releases/download/v${DB_CLI_VERSION}/databricks_cli_${DB_CLI_VERSION}_linux_amd64.zip" \
-o /tmp/dbcli.zip
unzip -o /tmp/dbcli.zip -d /tmp/dbcli
mv /tmp/dbcli/databricks "$INSTALL_DIR/databricks"
rm -rf /tmp/dbcli.zip /tmp/dbcli
chmod +x "$INSTALL_DIR/databricks"

"$INSTALL_DIR/databricks" --version
45 changes: 45 additions & 0 deletions install_gh.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/bash
# Install GitHub CLI (gh) to ~/.local/bin with an auth-login wrapper.
#
# - Fetches the latest 2.x release from the GitHub API
# - Installs to ~/.local/bin/gh.real
# - Creates a wrapper at ~/.local/bin/gh that intercepts `gh auth login`
# to skip interactive prompts (arrow-key menus break in xterm.js PTY)

set -euo pipefail

INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"

# Fetch latest release tag
GH_VERSION=$(curl -fsSL "https://api.github.com/repos/cli/cli/releases/latest" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['tag_name'].lstrip('v'))")

echo "Installing GitHub CLI v${GH_VERSION}"

curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \
-o /tmp/gh.tar.gz
tar -xzf /tmp/gh.tar.gz -C /tmp
mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" "$INSTALL_DIR/gh"
rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_amd64"
chmod +x "$INSTALL_DIR/gh"

# Set git protocol to HTTPS
"$INSTALL_DIR/gh" config set git_protocol https 2>/dev/null || true

# Create wrapper that intercepts `gh auth login` to avoid interactive prompts
cat > "$INSTALL_DIR/gh.wrapper" << 'WRAPPER'
#!/bin/bash
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
shift 2
printf "Y\\n" | ~/.local/bin/gh.real auth login -h github.com -p https -w --skip-ssh-key "$@"
exit 0
fi
exec ~/.local/bin/gh.real "$@"
WRAPPER

mv "$INSTALL_DIR/gh" "$INSTALL_DIR/gh.real"
mv "$INSTALL_DIR/gh.wrapper" "$INSTALL_DIR/gh"
chmod +x "$INSTALL_DIR/gh"

echo "GitHub CLI v${GH_VERSION} installed to $INSTALL_DIR"
54 changes: 31 additions & 23 deletions setup_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,29 @@
settings_path.write_text(json.dumps(settings, indent=2))

# 2. Write ~/.claude.json with onboarding skip AND MCP servers
mcp_servers = {
"deepwiki": {
"type": "http",
"url": "https://mcp.deepwiki.com/mcp"
},
"exa": {
"type": "http",
"url": "https://mcp.exa.ai/mcp"
}
}

# Auto-configure team-memory MCP if URL is provided
team_memory_url = os.environ.get("TEAM_MEMORY_MCP_URL", "").strip().rstrip("/")
if team_memory_url:
mcp_servers["team-memory"] = {
"type": "http",
"url": f"{team_memory_url}/mcp"
}
print(f"Team memory MCP configured: {team_memory_url}/mcp")

claude_json = {
"hasCompletedOnboarding": True,
"mcpServers": {
"deepwiki": {
"type": "http",
"url": "https://mcp.deepwiki.com/mcp"
},
"exa": {
"type": "http",
"url": "https://mcp.exa.ai/mcp"
}
}
"mcpServers": mcp_servers
}

claude_json_path = home / ".claude.json"
Expand All @@ -71,20 +82,17 @@
local_bin = home / ".local" / "bin"
claude_bin = local_bin / "claude"

if not claude_bin.exists():
print("Installing Claude Code CLI...")
result = subprocess.run(
["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"],
env={**os.environ, "HOME": str(home)},
capture_output=True,
text=True
)
if result.returncode == 0:
print("Claude Code CLI installed successfully")
else:
print(f"CLI install warning: {result.stderr}")
print("Installing/upgrading Claude Code CLI...")
result = subprocess.run(
["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"],
env={**os.environ, "HOME": str(home)},
capture_output=True,
text=True
)
if result.returncode == 0:
print("Claude Code CLI installed successfully")
else:
print(f"Claude Code CLI already installed at {claude_bin}")
print(f"CLI install warning: {result.stderr}")

# 4. Create projects directory
projects_dir = home / "projects"
Expand Down
40 changes: 38 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ <h3>Panes</h3>
<div class="shortcut-row"><span>Close pane</span><span>Alt+Shift+W</span></div>
<div class="shortcut-row"><span>Next pane</span><span>Alt+Shift+]</span></div>
<div class="shortcut-row"><span>Previous pane</span><span>Alt+Shift+[</span></div>
<h3>Clipboard</h3>
<div class="shortcut-row"><span>Copy</span><span id="sc-copy">Ctrl+C</span></div>
<div class="shortcut-row"><span>Paste</span><span id="sc-paste">Ctrl+V</span></div>
<div class="shortcut-row"><span>Paste image</span><span>Paste from clipboard</span></div>
<h3>General</h3>
<div class="shortcut-row"><span>Search</span><span>Ctrl+Shift+F</span></div>
<div class="shortcut-row"><span>Voice dictation</span><span>Alt+V</span></div>
Expand All @@ -379,6 +383,14 @@ <h3>General</h3>
<script src="/static/lib/addon-image.js"></script>
<script src="/static/lib/socket.io.min.js"></script>
<script>
// ── Platform-aware shortcut labels ──────────────────────────────
if (/Mac|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
const scCopy = document.getElementById('sc-copy');
const scPaste = document.getElementById('sc-paste');
if (scCopy) scCopy.textContent = 'Cmd+C';
if (scPaste) scPaste.textContent = 'Cmd+V';
}

// ── Theme Presets ──────────────────────────────────────────────
const themes = {
'Dark': {
Expand Down Expand Up @@ -1129,7 +1141,9 @@ <h3>General</h3>
cursorBlink: true,
fontSize: currentFontSize,
fontFamily: fontFamilies[currentFontFamily] || 'monospace',
theme: themes[currentThemeName].theme
theme: themes[currentThemeName].theme,
scrollback: 10000,
scrollOnUserInput: true
});

const fitAddon = new FitAddon.FitAddon();
Expand All @@ -1155,6 +1169,23 @@ <h3>General</h3>
term.open(element);
fitAddon.fit();

// On non-Mac platforms, let browser handle Ctrl+C (copy) and Ctrl+V (paste)
// so standard OS shortcuts work. On Mac, Cmd+C/Cmd+V already work natively.
const isMac = /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent);
if (!isMac) {
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
// Ctrl+V → let browser fire paste event
if (ctrlOnly && !e.shiftKey && e.code === 'KeyV') return false;
// Ctrl+C → copy selected text (if any), otherwise send SIGINT
if (ctrlOnly && !e.shiftKey && e.code === 'KeyC' && term.hasSelection()) return false;
// Ctrl+Shift+C/V → alternate copy/paste
if (ctrlOnly && e.shiftKey && (e.code === 'KeyC' || e.code === 'KeyV')) return false;
return true;
});
}

const sid = await createSession();
await sendResize(term.cols, term.rows, sid);

Expand Down Expand Up @@ -1552,6 +1583,7 @@ <h3>General</h3>
if (!blob) { console.log('[paste] Could not get file from item'); continue; }

console.log('[paste] Uploading image:', blob.type, blob.size, 'bytes');
showToast('Uploading image...');
const ext = item.type.split('/')[1] || 'png';
const formData = new FormData();
formData.append('file', blob, `clipboard-${Date.now()}.${ext}`);
Expand Down Expand Up @@ -1643,7 +1675,11 @@ <h3>General</h3>
status.textContent = 'Connected!';
setTimeout(() => { status.style.display = 'none'; }, 1000);

window.addEventListener('resize', () => refitAllPanes());
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => refitAllPanes(), 150);
});
window.addEventListener('beforeunload', () => cleanupAllPanes());

} catch (e) {
Expand Down