mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-11 00:27:55 +02:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
Generated
+17
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
export default function AgentShellPanel({ anchorRef, active }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [size, setSize] = useState<ShellSize>({ 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<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(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 (
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
|
||||
<Terminal size={18} className="text-cyan-400 mb-2" />
|
||||
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">AGENT SHELL</div>
|
||||
<Terminal size={16} className="text-cyan-400 mb-2" />
|
||||
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">LOCAL SHELL</div>
|
||||
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||
Expand Mesh Chat to open the local agent shell.
|
||||
Expand Mesh Chat to open your operator shell.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shell = (
|
||||
<div
|
||||
className="pointer-events-auto z-[250] flex flex-col border border-cyan-800/50 bg-[#05080c]/96 shadow-[0_18px_60px_rgba(0,0,0,0.55),0_0_0_1px_rgba(34,211,238,0.08)] backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
transition: userResized ? undefined : 'width 180ms ease-out',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-3 py-2 shrink-0 select-none">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Terminal size={13} className="text-cyan-400 shrink-0" />
|
||||
<span className="text-[13px] font-mono tracking-[0.18em] text-cyan-300">AGENT SHELL</span>
|
||||
<span className="hidden sm:inline text-[12px] font-mono text-slate-500 truncate">
|
||||
local CLI · user cwd
|
||||
</span>
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
|
||||
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
|
||||
{cwd ? cwd : 'operator shell'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(true)}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
>
|
||||
EXPAND
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(false)}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
>
|
||||
SNAP
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={snapToStretchedDefault}
|
||||
className="px-2 py-1 text-[12px] font-mono tracking-[0.14em] text-cyan-300/80 border border-cyan-800/40 hover:bg-cyan-950/30 transition-colors"
|
||||
title="Reset size to default stretch from Mesh Chat panel"
|
||||
onClick={connect}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
|
||||
>
|
||||
SNAP
|
||||
RECONNECT
|
||||
</button>
|
||||
<GripHorizontal size={14} className="text-cyan-600/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-auto styled-scrollbar px-3 py-2 font-mono text-cyan-100/90"
|
||||
style={{ fontSize: SHELL_FONT_PX, lineHeight: 1.55 }}
|
||||
>
|
||||
<div className="text-slate-400">ShadowBroker agent shell (PTY wiring next)</div>
|
||||
<div className="text-slate-500 mt-1">Working directory: set your own path in Settings.</div>
|
||||
<div className="mt-3 text-emerald-300/90">$ openclaw</div>
|
||||
<div className="text-slate-500">$ codex</div>
|
||||
<div className="text-slate-500">$ gemini</div>
|
||||
<div className="mt-3 text-cyan-300/80 animate-pulse">█</div>
|
||||
</div>
|
||||
{status === 'error' && statusDetail && (
|
||||
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
|
||||
{statusDetail}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-cyan-900/30 px-3 py-1.5 text-[12px] font-mono text-slate-500 shrink-0">
|
||||
Drag right/bottom edges to resize · {Math.round(size.w)}×{Math.round(size.h)}px · {SHELL_FONT_PX}px font
|
||||
</div>
|
||||
<div ref={hostRef} className="flex-1 min-h-[220px] px-1 py-1 overflow-hidden" />
|
||||
|
||||
<div
|
||||
className="absolute top-2 bottom-2 right-0 w-1.5 cursor-e-resize"
|
||||
onMouseDown={beginResize('e')}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute left-2 right-2 bottom-0 h-1.5 cursor-s-resize"
|
||||
onMouseDown={beginResize('s')}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 bottom-0 h-3 w-3 cursor-se-resize"
|
||||
onMouseDown={beginResize('se')}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="border-t border-cyan-900/30 px-2 py-1 text-[11px] font-mono text-slate-500 shrink-0">
|
||||
{status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'open'
|
||||
? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive`
|
||||
: 'Local operator shell'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-3 py-4 text-center border-l-2 border-cyan-800/20">
|
||||
<div className="text-[12px] font-mono tracking-[0.16em] text-cyan-500/80">SHELL ACTIVE</div>
|
||||
<div className="mt-1 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||
Panel stretched from Mesh Chat. Drag edges on the shell to resize.
|
||||
</div>
|
||||
</div>
|
||||
{createPortal(shell, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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 */}
|
||||
<div
|
||||
ref={panelBoxRef}
|
||||
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden`}
|
||||
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden transition-[width,min-width] duration-200 ${
|
||||
activeTab === 'dms' && shellExpanded
|
||||
? 'z-[210] min-w-[min(760px,calc(100vw-3rem))] w-[min(760px,calc(100vw-3rem))]'
|
||||
: ''
|
||||
}`}
|
||||
style={{ boxShadow: '0 0 15px rgba(8,145,178,0.06), inset 0 0 20px rgba(0,0,0,0.4)', ...(expanded ? { flex: '1 1 0', minHeight: 0 } : {}) }}
|
||||
>
|
||||
{/* HEADER */}
|
||||
@@ -438,8 +450,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
{ key: 'meshtastic' as Tab, label: 'MESH', icon: <Radio size={10} />, badge: 0 },
|
||||
{
|
||||
key: 'dms' as Tab,
|
||||
label: 'AGENT SHELL',
|
||||
icon: <Terminal size={10} />,
|
||||
label: 'SHELL',
|
||||
icon: <SquareTerminal size={10} />,
|
||||
badge: 0,
|
||||
},
|
||||
].map((tab) => (
|
||||
@@ -480,21 +492,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
|
||||
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
|
||||
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
||||
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is
|
||||
blocked until Wormhole is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
|
||||
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
|
||||
<div className="px-3 py-2 text-sm font-mono text-yellow-400/80 border-b border-yellow-900/20 bg-yellow-950/10 leading-[1.65] shrink-0">
|
||||
Wormhole secure mode is active. Experimental private-lane operations are routed
|
||||
through the local agent and current secure transport paths.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
|
||||
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
|
||||
<div className="px-3 py-2 text-sm font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/20 leading-[1.65] shrink-0">
|
||||
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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
|
||||
{activeTab !== 'dms' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
|
||||
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
||||
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) {
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{activeTab === 'dms' && (
|
||||
<AgentShellPanel
|
||||
anchorRef={panelBoxRef}
|
||||
active={expanded && activeTab === 'dms'}
|
||||
expanded={shellExpanded}
|
||||
onExpandedChange={setShellExpanded}
|
||||
/>
|
||||
)}
|
||||
{dashboardRestrictedTab && (
|
||||
@@ -1502,7 +1515,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
SHELL
|
||||
</span>
|
||||
<div className="px-3 py-2.5 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
) : dashboardRestrictedTab ? (
|
||||
|
||||
@@ -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}` : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user