From d1e1be4016d9fcccd61a68273c96150c0bb7196d Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:30:50 -0600 Subject: [PATCH] Replace mock Agent Shell overlay with inline xterm PTY and dock/expand UX. Uses a local-operator WebSocket bash session, keeps the map interactive, and SNAP docks the shell back into Mesh Chat instead of a floating blurred panel. Co-authored-by: Cursor --- backend/main.py | 2 + backend/routers/agent_shell.py | 195 ++++++++ backend/services/agent_shell_settings.py | 48 ++ backend/tests/test_agent_shell_settings.py | 14 + frontend/package-lock.json | 18 +- frontend/package.json | 2 + .../__tests__/mesh/meshChatBehavior.test.ts | 2 +- .../components/MeshChat/AgentShellPanel.tsx | 425 ++++++++---------- frontend/src/components/MeshChat/index.tsx | 33 +- frontend/src/lib/agentShellWs.ts | 13 + 10 files changed, 503 insertions(+), 249 deletions(-) create mode 100644 backend/routers/agent_shell.py create mode 100644 backend/services/agent_shell_settings.py create mode 100644 backend/tests/test_agent_shell_settings.py create mode 100644 frontend/src/lib/agentShellWs.ts diff --git a/backend/main.py b/backend/main.py index fd9c163..1783739 100644 --- a/backend/main.py +++ b/backend/main.py @@ -370,6 +370,7 @@ osint_router = _load_optional_router("routers.osint") scm_router = _load_optional_router("routers.scm") entity_graph_router = _load_optional_router("routers.entity_graph") intel_feeds_router = _load_optional_router("routers.intel_feeds") +agent_shell_router = _load_optional_router("routers.agent_shell") # --------------------------------------------------------------------------- @@ -3651,6 +3652,7 @@ app.include_router(osint_router) app.include_router(scm_router) app.include_router(entity_graph_router) app.include_router(intel_feeds_router) +app.include_router(agent_shell_router) from services.data_fetcher import update_all_data diff --git a/backend/routers/agent_shell.py b/backend/routers/agent_shell.py new file mode 100644 index 0000000..56ddf6b --- /dev/null +++ b/backend/routers/agent_shell.py @@ -0,0 +1,195 @@ +"""Local-operator PTY WebSocket for the Mesh Chat agent shell.""" + +from __future__ import annotations + +import asyncio +import fcntl +import hmac +import json +import logging +import os +import pty +import select +import signal +import struct +import sys +import termios +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field + +from auth import ( + _current_admin_key, + _debug_mode_enabled, + _is_trusted_local_runtime_host, + require_local_operator, +) +from services.agent_shell_settings import ( + get_agent_shell_settings, + set_agent_shell_working_directory, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["agent-shell"]) + + +class AgentShellSettingsUpdate(BaseModel): + working_directory: str = Field(min_length=1) + + +def _set_winsize(fd: int, rows: int, cols: int) -> None: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None: + host = (ws.client.host or "").lower() if ws.client else "" + if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"): + return + admin_key = _current_admin_key() + presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip() + if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()): + return + await ws.close(code=4403, reason="local operator access only") + raise WebSocketDisconnect() + + +def _resolve_shell_cwd(requested: str) -> str: + requested = str(requested or "").strip() + if requested: + resolved = os.path.abspath(os.path.expanduser(requested)) + if os.path.isdir(resolved): + return resolved + return get_agent_shell_settings()["working_directory"] + + +def _default_shell() -> str: + if sys.platform == "win32": + return os.environ.get("COMSPEC", "cmd.exe") + return os.environ.get("SHELL", "/bin/bash") + + +async def _relay_pty(master_fd: int, proc: asyncio.subprocess.Process, ws: WebSocket) -> None: + loop = asyncio.get_running_loop() + while True: + if proc.returncode is not None: + break + try: + readable, _, _ = await loop.run_in_executor( + None, lambda: select.select([master_fd], [], [], 0.05) + ) + except Exception: + break + if master_fd in readable: + try: + chunk = os.read(master_fd, 4096) + except OSError: + break + if not chunk: + break + await ws.send_bytes(chunk) + try: + message = await asyncio.wait_for(ws.receive(), timeout=0.05) + except asyncio.TimeoutError: + continue + if message.get("type") == "websocket.disconnect": + break + if message.get("type") != "websocket.receive": + continue + if message.get("bytes"): + os.write(master_fd, message["bytes"]) + continue + text = message.get("text") + if not text: + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + os.write(master_fd, text.encode("utf-8", errors="replace")) + continue + if payload.get("type") == "resize": + rows = int(payload.get("rows") or 24) + cols = int(payload.get("cols") or 80) + _set_winsize(master_fd, max(rows, 2), max(cols, 2)) + + +@router.get("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)]) +async def read_agent_shell_settings() -> dict[str, Any]: + return get_agent_shell_settings() + + +@router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)]) +async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]: + try: + return set_agent_shell_working_directory(body.working_directory) + except ValueError as exc: + detail = str(exc) + if detail == "working_directory_not_found": + raise HTTPException(status_code=400, detail="Working directory does not exist") from exc + raise HTTPException(status_code=400, detail="Working directory is required") from exc + + +@router.websocket("/api/agent-shell/ws") +async def agent_shell_websocket( + ws: WebSocket, + cwd: str = Query(default=""), + cols: int = Query(default=80), + rows: int = Query(default=24), + admin_key: str = Query(default=""), +) -> None: + await ws.accept() + try: + await _authorize_agent_shell_ws(ws, admin_key) + except WebSocketDisconnect: + return + + if sys.platform == "win32": + await ws.send_text( + json.dumps( + { + "type": "error", + "message": "Host PTY is not available on Windows backend builds yet. Use the ShadowBroker desktop app or run the backend in Docker/Linux for an embedded shell.", + } + ) + ) + await ws.close(code=1011) + return + + shell_cwd = _resolve_shell_cwd(cwd) + shell = _default_shell() + master_fd, slave_fd = pty.openpty() + _set_winsize(master_fd, max(rows, 2), max(cols, 2)) + + env = os.environ.copy() + env.setdefault("TERM", "xterm-256color") + env.setdefault("COLORTERM", "truecolor") + + proc = await asyncio.create_subprocess_exec( + shell, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=shell_cwd, + env=env, + preexec_fn=os.setsid, + ) + os.close(slave_fd) + + try: + await _relay_pty(master_fd, proc, ws) + finally: + try: + os.close(master_fd) + except OSError: + pass + if proc.returncode is None: + try: + os.killpg(proc.pid, signal.SIGHUP) + except ProcessLookupError: + pass + try: + await asyncio.wait_for(proc.wait(), timeout=2.0) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() diff --git a/backend/services/agent_shell_settings.py b/backend/services/agent_shell_settings.py new file mode 100644 index 0000000..3fd50e2 --- /dev/null +++ b/backend/services/agent_shell_settings.py @@ -0,0 +1,48 @@ +"""Operator settings for the embedded agent shell (working directory).""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_SETTINGS_FILE = Path(__file__).resolve().parent.parent / "data" / "agent_shell_settings.json" +_LOCK = threading.Lock() + + +def _default_working_directory() -> str: + return os.environ.get("AGENT_SHELL_DEFAULT_CWD") or os.environ.get("HOME") or "/app" + + +def get_agent_shell_settings() -> dict[str, Any]: + with _LOCK: + if not _SETTINGS_FILE.exists(): + return {"working_directory": _default_working_directory()} + try: + payload = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + logger.warning("agent_shell_settings_unreadable") + return {"working_directory": _default_working_directory()} + cwd = str(payload.get("working_directory") or "").strip() or _default_working_directory() + return {"working_directory": cwd} + + +def set_agent_shell_working_directory(path: str) -> dict[str, Any]: + normalized = str(path or "").strip() + if not normalized: + raise ValueError("working_directory_required") + resolved = os.path.abspath(os.path.expanduser(normalized)) + if not os.path.isdir(resolved): + raise ValueError("working_directory_not_found") + with _LOCK: + _SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_FILE.write_text( + json.dumps({"working_directory": resolved}, indent=2) + "\n", + encoding="utf-8", + ) + return {"working_directory": resolved} diff --git a/backend/tests/test_agent_shell_settings.py b/backend/tests/test_agent_shell_settings.py new file mode 100644 index 0000000..a5b100c --- /dev/null +++ b/backend/tests/test_agent_shell_settings.py @@ -0,0 +1,14 @@ +def test_agent_shell_settings_roundtrip(tmp_path, monkeypatch): + from services import agent_shell_settings + + settings_path = tmp_path / "agent_shell_settings.json" + workdir = tmp_path / "workspace" + workdir.mkdir() + + monkeypatch.setattr(agent_shell_settings, "_SETTINGS_FILE", settings_path) + + assert agent_shell_settings.get_agent_shell_settings()["working_directory"] + + saved = agent_shell_settings.set_agent_shell_working_directory(str(workdir)) + assert saved["working_directory"] == str(workdir.resolve()) + assert agent_shell_settings.get_agent_shell_settings()["working_directory"] == str(workdir.resolve()) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c11327d..17ab107 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@mapbox/point-geometry": "^1.1.0", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "framer-motion": "^12.38.0", "hls.js": "^1.6.15", "lucide-react": "^0.575.0", @@ -3251,6 +3253,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7374,7 +7391,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", diff --git a/frontend/package.json b/frontend/package.json index 0c4772f..2e09ec1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,8 @@ "@mapbox/point-geometry": "^1.1.0", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "framer-motion": "^12.38.0", "hls.js": "^1.6.15", "lucide-react": "^0.575.0", diff --git a/frontend/src/__tests__/mesh/meshChatBehavior.test.ts b/frontend/src/__tests__/mesh/meshChatBehavior.test.ts index 6cb914c..a95ce5c 100644 --- a/frontend/src/__tests__/mesh/meshChatBehavior.test.ts +++ b/frontend/src/__tests__/mesh/meshChatBehavior.test.ts @@ -127,7 +127,7 @@ describe('MeshChat behavior - policy wiring', () => { it('MeshChat Agent Shell tab keeps legacy dm-add guidance in MeshTerminal', () => { const index = readSource('../../components/MeshChat/index.tsx'); expect(index).toContain('AgentShellPanel'); - expect(index).toContain('AGENT SHELL'); + expect(index).toContain('SHELL'); expect(index).not.toContain('handleAddContact().catch(() =>'); const terminal = readSource('../../components/MeshTerminal.tsx'); expect(terminal).toContain('dm add'); diff --git a/frontend/src/components/MeshChat/AgentShellPanel.tsx b/frontend/src/components/MeshChat/AgentShellPanel.tsx index cad1362..04297da 100644 --- a/frontend/src/components/MeshChat/AgentShellPanel.tsx +++ b/frontend/src/components/MeshChat/AgentShellPanel.tsx @@ -1,281 +1,232 @@ 'use client'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { GripHorizontal, Terminal } from 'lucide-react'; +import { Terminal } from 'lucide-react'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; +import { resolveAgentShellWsUrl } from '@/lib/agentShellWs'; -const STORAGE_KEY = 'sb_agent_shell_dims'; const SHELL_FONT_PX = 14; -const MIN_SHELL_WIDTH = 300; -const MIN_SHELL_HEIGHT = 220; -const STRETCH_WIDTH_RATIO = 2.15; -const STRETCH_MIN_WIDTH = 520; - -type ShellSize = { w: number; h: number }; - -function readStoredSize(): ShellSize | null { - if (typeof window === 'undefined') return null; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as ShellSize; - if ( - typeof parsed?.w === 'number' && - typeof parsed?.h === 'number' && - parsed.w >= MIN_SHELL_WIDTH && - parsed.h >= MIN_SHELL_HEIGHT - ) { - return parsed; - } - } catch { - /* ignore */ - } - return null; -} - -function writeStoredSize(size: ShellSize) { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(size)); - } catch { - /* ignore */ - } -} - -function clampSize(size: ShellSize, anchorLeft: number): ShellSize { - const maxW = Math.max(MIN_SHELL_WIDTH, window.innerWidth - anchorLeft - 12); - const maxH = Math.max(MIN_SHELL_HEIGHT, window.innerHeight - 12); - return { - w: Math.min(Math.max(size.w, MIN_SHELL_WIDTH), maxW), - h: Math.min(Math.max(size.h, MIN_SHELL_HEIGHT), maxH), - }; -} - -function defaultStretchedSize(anchor: DOMRect): ShellSize { - const stretchedW = Math.max(anchor.width * STRETCH_WIDTH_RATIO, STRETCH_MIN_WIDTH); - return clampSize({ w: stretchedW, h: anchor.height }, anchor.left); -} +const CWD_STORAGE_KEY = 'sb_agent_shell_cwd'; type Props = { - anchorRef: React.RefObject; active: boolean; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; }; -export default function AgentShellPanel({ anchorRef, active }: Props) { - const [mounted, setMounted] = useState(false); - const [anchorRect, setAnchorRect] = useState(null); - const [size, setSize] = useState({ w: STRETCH_MIN_WIDTH, h: 360 }); - const [pos, setPos] = useState({ x: 0, y: 0 }); - const [userResized, setUserResized] = useState(Boolean(readStoredSize())); - const resizeRef = useRef<{ - edge: 'e' | 's' | 'se'; - startX: number; - startY: number; - origW: number; - origH: number; - } | null>(null); +function readStoredCwd(): string { + if (typeof window === 'undefined') return ''; + try { + return window.localStorage.getItem(CWD_STORAGE_KEY) || ''; + } catch { + return ''; + } +} - useEffect(() => { - setMounted(true); +export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) { + const hostRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle'); + const [statusDetail, setStatusDetail] = useState(''); + const [cwd, setCwd] = useState(''); + + const disconnect = useCallback(() => { + wsRef.current?.close(); + wsRef.current = null; + termRef.current?.dispose(); + termRef.current = null; + fitRef.current = null; + setStatus('idle'); }, []); - const measureAnchor = useCallback(() => { - const el = anchorRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - setAnchorRect(rect); - setPos({ x: rect.left, y: rect.top }); + const fitTerminal = useCallback(() => { + const fit = fitRef.current; + const term = termRef.current; + const ws = wsRef.current; + if (!fit || !term) return; + fit.fit(); + if (ws?.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + }), + ); + } + }, []); - if (!userResized) { - setSize(defaultStretchedSize(rect)); + const connect = useCallback(() => { + if (!active || !hostRef.current) return; + disconnect(); + + const term = new XTerm({ + fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + fontSize: SHELL_FONT_PX, + lineHeight: 1.35, + cursorBlink: true, + theme: { + background: '#04070b', + foreground: '#d9f7ff', + cursor: '#22d3ee', + selectionBackground: '#0e7490', + }, + scrollback: 5000, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(hostRef.current); + fit.fit(); + termRef.current = term; + fitRef.current = fit; + + const storedCwd = readStoredCwd(); + setCwd(storedCwd); + setStatus('connecting'); + setStatusDetail(''); + + const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd)); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onopen = () => { + setStatus('open'); + fit.fit(); + ws.send( + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + }), + ); + term.focus(); + }; + + ws.onmessage = (event) => { + if (typeof event.data === 'string') { + try { + const payload = JSON.parse(event.data) as { type?: string; message?: string }; + if (payload.type === 'error') { + setStatus('error'); + setStatusDetail(payload.message || 'Shell unavailable'); + term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`); + return; + } + } catch { + term.write(event.data); + return; + } + } + if (event.data instanceof ArrayBuffer) { + term.write(new Uint8Array(event.data)); + } + }; + + ws.onerror = () => { + setStatus('error'); + setStatusDetail('Could not connect to the local agent shell endpoint.'); + term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m'); + }; + + ws.onclose = () => { + setStatus((prev) => (prev === 'error' ? prev : 'idle')); + term.writeln('\r\n\x1b[90m[session closed]\x1b[0m'); + }; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(new TextEncoder().encode(data)); + } + }); + }, [active, disconnect]); + + useEffect(() => { + if (!active) { + disconnect(); return; } - - const stored = readStoredSize(); - if (stored) { - setSize(clampSize(stored, rect.left)); - } else { - setSize(defaultStretchedSize(rect)); - } - }, [anchorRef, userResized]); + connect(); + return () => disconnect(); + }, [active, connect, disconnect]); useEffect(() => { if (!active) return; - measureAnchor(); - - const el = anchorRef.current; - if (!el) return; - - const observer = new ResizeObserver(() => measureAnchor()); - observer.observe(el); - - const onWindowChange = () => measureAnchor(); - window.addEventListener('resize', onWindowChange); - window.addEventListener('scroll', onWindowChange, true); - - return () => { - observer.disconnect(); - window.removeEventListener('resize', onWindowChange); - window.removeEventListener('scroll', onWindowChange, true); - }; - }, [active, anchorRef, measureAnchor]); + const timer = window.setTimeout(() => fitTerminal(), expanded ? 220 : 0); + return () => window.clearTimeout(timer); + }, [active, expanded, fitTerminal]); useEffect(() => { - if (!active || userResized) return; - const el = anchorRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const base = { w: rect.width, h: rect.height }; - setSize(base); - setPos({ x: rect.left, y: rect.top }); + if (!active) return; + const onResize = () => fitTerminal(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [active, fitTerminal]); - const frame = window.requestAnimationFrame(() => { - setSize(defaultStretchedSize(rect)); - }); - return () => window.cancelAnimationFrame(frame); - }, [active, anchorRef, userResized]); - - const beginResize = (edge: 'e' | 's' | 'se') => (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - resizeRef.current = { - edge, - startX: event.clientX, - startY: event.clientY, - origW: size.w, - origH: size.h, - }; - - const onMove = (ev: MouseEvent) => { - if (!resizeRef.current) return; - const dx = ev.clientX - resizeRef.current.startX; - const dy = ev.clientY - resizeRef.current.startY; - const { edge: ed, origW, origH } = resizeRef.current; - const anchorLeft = anchorRef.current?.getBoundingClientRect().left ?? pos.x; - const next: ShellSize = { - w: ed === 's' ? origW : origW + dx, - h: ed === 'e' ? origH : origH + dy, - }; - setUserResized(true); - setSize(clampSize(next, anchorLeft)); - }; - - const onUp = () => { - resizeRef.current = null; - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - setSize((current) => { - writeStoredSize(current); - return current; - }); - }; - - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - }; - - const snapToStretchedDefault = () => { - const el = anchorRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - setUserResized(false); - try { - window.localStorage.removeItem(STORAGE_KEY); - } catch { - /* ignore */ - } - setPos({ x: rect.left, y: rect.top }); - setSize(defaultStretchedSize(rect)); - }; - - if (!mounted || !active || !anchorRect) { + if (!active) { return (
- -
AGENT SHELL
+ +
LOCAL SHELL
- Expand Mesh Chat to open the local agent shell. + Expand Mesh Chat to open your operator shell.
); } - const shell = ( -
-
-
- - AGENT SHELL - - local CLI · user cwd - + return ( +
+
+
+ {cwd ? cwd : 'operator shell'}
+ {!expanded ? ( + + ) : ( + + )} -
-
-
ShadowBroker agent shell (PTY wiring next)
-
Working directory: set your own path in Settings.
-
$ openclaw
-
$ codex
-
$ gemini
-
-
+ {status === 'error' && statusDetail && ( +
+ {statusDetail} +
+ )} -
- Drag right/bottom edges to resize · {Math.round(size.w)}×{Math.round(size.h)}px · {SHELL_FONT_PX}px font -
+
-
-
-
+
+ {status === 'connecting' + ? 'Connecting…' + : status === 'open' + ? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive` + : 'Local operator shell'} +
); - - return ( - <> -
-
SHELL ACTIVE
-
- Panel stretched from Mesh Chat. Drag edges on the shell to resize. -
-
- {createPortal(shell, document.body)} - - ); } diff --git a/frontend/src/components/MeshChat/index.tsx b/frontend/src/components/MeshChat/index.tsx index bf13cee..0682343 100644 --- a/frontend/src/components/MeshChat/index.tsx +++ b/frontend/src/components/MeshChat/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import AgentShellPanel from './AgentShellPanel'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -13,6 +13,7 @@ import { Radio, Shield, Terminal, + SquareTerminal, UserPlus, Lock, Check, @@ -91,6 +92,7 @@ function describeGateCompatReason(reason: string, gateId: string): string { const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { const panelBoxRef = useRef(null); + const [shellExpanded, setShellExpanded] = useState(false); const ctrl = useMeshChatController(props); const { // UI state @@ -319,6 +321,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { recentPrivateFallbackReason, onSettingsClick, } = ctrl; + + useEffect(() => { + if (activeTab !== 'dms') { + setShellExpanded(false); + } + }, [activeTab]); const selectedContactTrustSummary = selectedContactInfo ? getContactTrustSummary(selectedContactInfo) : null; @@ -401,7 +409,11 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { {/* Single unified box — matches Data Layers panel skin */}
{/* HEADER */} @@ -438,8 +450,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { { key: 'meshtastic' as Tab, label: 'MESH', icon: , badge: 0 }, { key: 'dms' as Tab, - label: 'AGENT SHELL', - icon: , + label: 'SHELL', + icon: , badge: 0, }, ].map((tab) => ( @@ -480,21 +492,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)} - {activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && ( + {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is blocked until Wormhole is running.
)} - {activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && ( + {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
Wormhole secure mode is active. Experimental private-lane operations are routed through the local agent and current secure transport paths.
)} - {activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && ( + {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
TRANSITIONAL PRIVATE LANE. Wormhole is up and gate chat is available on the transitional lane. Reticulum is still warming — Dead Drop / DM requires the @@ -502,7 +514,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)} - {activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && ( + {activeTab !== 'dms' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked until Wormhole is running over Tor, I2P, or Mixnet. @@ -545,8 +557,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
{activeTab === 'dms' && ( )} {dashboardRestrictedTab && ( @@ -1502,7 +1515,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { SHELL
- Local bash/cmd launches here (desktop). Set your own working directory in Settings. + Real local shell over PTY. EXPAND widens this panel; SNAP docks it back into Mesh Chat.
) : dashboardRestrictedTab ? ( diff --git a/frontend/src/lib/agentShellWs.ts b/frontend/src/lib/agentShellWs.ts new file mode 100644 index 0000000..aa887f5 --- /dev/null +++ b/frontend/src/lib/agentShellWs.ts @@ -0,0 +1,13 @@ +export function resolveAgentShellWsUrl(cwd?: string): string { + if (typeof window === 'undefined') return ''; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const host = window.location.hostname || '127.0.0.1'; + const port = + process.env.NEXT_PUBLIC_BACKEND_PORT || + (window.location.port === '3000' ? '8000' : window.location.port || '8000'); + const params = new URLSearchParams(); + const trimmed = String(cwd || '').trim(); + if (trimmed) params.set('cwd', trimmed); + const query = params.toString(); + return `${protocol}://${host}:${port}/api/agent-shell/ws${query ? `?${query}` : ''}`; +}