diff --git a/README.md b/README.md index 1c80520..3b790a9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes. +- **Any coding agent**: Universal API to interact with all agents with full feature coverage +- **Server Mode**: Run as HTTP server from any sandbox provider or as TypeScript & Python SDK +- **Universal session schema**: Universal schema to store agent transcripts +- **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, and more +- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command + Documentation lives in `docs/` (Mintlify). Start with: - `docs/index.mdx` for the overview @@ -23,3 +29,24 @@ sandbox-agent credentials extract-env # Export to current shell eval "$(sandbox-agent credentials extract-env --export)" ``` + +Run the web console (includes all dependencies): + +```bash +pnpm dev -F @sandbox-agent/web +``` + +## Project Scope + +This project aims to solve 3 problems with agents: + +- **Universal Agent API**: Claude Code, Codex, Amp, and OpenCode all have put a lot of work in to the agent scaffold. Each have respective pros and cons and need to be easy to be swapped between. +- **Agent Transcript**: Maintaining agent transcripts is difficult since the agent manages its own sessions. This provides a simpler way to read and retrieve agent transcripts in your system. +- **Agents In Sandboxes**: There are many complications with running agents inside of sandbox providers. This lets you run a simple curl command to spawn an HTTP server for using any agent from within the sandbox. + +Features out of scope: + +- **Storage of sessions on disk**: Sessions are already stored by the respective coding agents on disk. It's assumed that the consumer is streaming data from this machine to an external storage, such as Postgres, ClickHouse, or Rivet. +- **Direct LLM wrappers**: Use the [Vercel AI SDK](https://ai-sdk.dev/docs/introduction) if you want to implement your own agent from scratch. +- **Git Repo Management**: Just use git commands or the features provided by your sandbox provider of choice. +- **Sandbox Provider API**: Sandbox providers have many nuanced differences in their API, it does not make sense for us to try to provide a custom layer. Instead, we opt to provide guides that let you integrate this project with sandbox providers. diff --git a/engine/packages/sandbox-agent/src/router.rs b/engine/packages/sandbox-agent/src/router.rs index 0c51188..eae41e3 100644 --- a/engine/packages/sandbox-agent/src/router.rs +++ b/engine/packages/sandbox-agent/src/router.rs @@ -505,6 +505,24 @@ impl SessionManager { Ok(EventsResponse { events, has_more }) } + async fn list_sessions(&self) -> Vec { + let sessions = self.sessions.lock().await; + sessions + .values() + .map(|state| SessionInfo { + session_id: state.session_id.clone(), + agent: state.agent.as_str().to_string(), + agent_mode: state.agent_mode.clone(), + permission_mode: state.permission_mode.clone(), + model: state.model.clone(), + variant: state.variant.clone(), + agent_session_id: state.agent_session_id.clone(), + ended: state.ended, + event_count: state.events.len() as u64, + }) + .collect() + } + async fn subscribe( &self, session_id: &str, @@ -1247,6 +1265,25 @@ pub struct AgentListResponse { pub agents: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub session_id: String, + pub agent: String, + pub agent_mode: String, + pub permission_mode: String, + pub model: Option, + pub variant: Option, + pub agent_session_id: Option, + pub ended: bool, + pub event_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +pub struct SessionListResponse { + pub sessions: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct HealthResponse { diff --git a/frontend/packages/web/index.html b/frontend/packages/web/index.html index 9c6d335..a9e14b5 100644 --- a/frontend/packages/web/index.html +++ b/frontend/packages/web/index.html @@ -103,7 +103,13 @@ gap: 12px; } - .status-indicator { + .header-endpoint { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + font-size: 11px; + color: var(--muted); + } + + .status-indicator.disconnected { display: flex; align-items: center; gap: 8px; @@ -113,14 +119,6 @@ border: 1px solid var(--border-2); font-size: 11px; font-weight: 600; - } - - .status-indicator.connected { - color: var(--success); - border-color: rgba(48, 209, 88, 0.3); - } - - .status-indicator.disconnected { color: var(--muted); } @@ -388,7 +386,8 @@ } .panel-header { - padding: 10px 16px; + height: 41px; + padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--surface-2); flex-shrink: 0; @@ -412,13 +411,41 @@ color: var(--muted); } - .session-badge { + .session-input { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; - font-size: 11px; - color: var(--text-secondary); + font-size: 12px; + color: var(--text); background: var(--surface); - padding: 3px 8px; + border: 1px solid var(--border-2); + padding: 4px 10px; border-radius: 4px; + outline: none; + width: 140px; + transition: border-color var(--transition); + } + + .session-input:focus { + border-color: var(--accent); + } + + .session-input::placeholder { + color: var(--muted-2); + } + + .session-new-btn { + background: var(--accent); + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + color: #fff; + cursor: pointer; + transition: background var(--transition); + } + + .session-new-btn:hover { + background: var(--accent-hover); } .messages-container { @@ -517,6 +544,16 @@ border-bottom-left-radius: 4px; } + .message-error { + background: rgba(255, 59, 48, 0.1); + border: 1px solid rgba(255, 59, 48, 0.3); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: var(--danger); + font-size: 12px; + margin-top: 8px; + } + .cursor { display: inline-block; width: 2px; @@ -601,6 +638,143 @@ height: 16px; } + /* Setup Row */ + .setup-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--surface-2); + border-top: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + } + + .setup-select { + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--text); + cursor: pointer; + outline: none; + min-width: 70px; + } + + .setup-select:focus { + border-color: var(--accent); + } + + .setup-input { + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--text); + outline: none; + width: 70px; + } + + .setup-input-wide { + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + color: var(--text); + outline: none; + width: 120px; + } + + .setup-input::placeholder, + .setup-input-wide::placeholder { + color: var(--muted-2); + } + + .setup-input:focus, + .setup-input-wide:focus { + border-color: var(--accent); + } + + .setup-new-btn { + background: var(--accent); + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + color: #fff; + cursor: pointer; + transition: background var(--transition); + } + + .setup-new-btn:hover { + background: var(--accent-hover); + } + + .setup-divider { + width: 1px; + height: 16px; + background: var(--border-2); + margin: 0 4px; + } + + .setup-stream { + display: flex; + align-items: center; + gap: 4px; + } + + .setup-select-small { + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 4px 6px; + font-size: 10px; + color: var(--text); + cursor: pointer; + outline: none; + width: 50px; + } + + .setup-stream-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + transition: all var(--transition); + } + + .setup-stream-btn:hover { + border-color: var(--accent); + color: var(--accent); + } + + .setup-stream-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + } + + .setup-version { + font-size: 10px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + background: var(--surface); + padding: 4px 8px; + border-radius: 4px; + margin-left: auto; + } + /* Debug Panel */ .debug-panel { display: flex; @@ -610,6 +784,7 @@ .debug-tabs { display: flex; + height: 41px; border-bottom: 1px solid var(--border); background: var(--surface-2); flex-shrink: 0; @@ -617,7 +792,8 @@ } .debug-tab { - padding: 10px 16px; + height: 100%; + padding: 0 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; @@ -629,6 +805,8 @@ cursor: pointer; transition: color var(--transition), border-color var(--transition); white-space: nowrap; + display: flex; + align-items: center; } .debug-tab:hover { diff --git a/frontend/packages/web/src/App.tsx b/frontend/packages/web/src/App.tsx index 8a03dc3..d653a22 100644 --- a/frontend/packages/web/src/App.tsx +++ b/frontend/packages/web/src/App.tsx @@ -1,17 +1,16 @@ import { - AlertCircle, - CheckCircle2, Clipboard, Cloud, Download, HelpCircle, + MessageSquare, PauseCircle, PlayCircle, - PlugZap, RefreshCw, Send, Shield, - TerminalSquare + Terminal, + Zap } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -106,6 +105,8 @@ type RequestLog = { error?: string; }; +type DebugTab = "log" | "events" | "approvals" | "agents"; + const defaultAgents = ["claude", "codex", "opencode", "amp"]; const buildUrl = (endpoint: string, path: string, query?: Record) => { @@ -123,9 +124,7 @@ const buildUrl = (endpoint: string, path: string, query?: Record }; const safeJson = (text: string) => { - if (!text) { - return null; - } + if (!text) return null; try { return JSON.parse(text); } catch { @@ -134,12 +133,8 @@ const safeJson = (text: string) => { }; const formatJson = (value: unknown) => { - if (value === null || value === undefined) { - return ""; - } - if (typeof value === "string") { - return value; - } + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { @@ -190,13 +185,11 @@ export default function App() { const [modesByAgent, setModesByAgent] = useState>({}); const [agentId, setAgentId] = useState("claude"); - const [agentMode, setAgentMode] = useState("build"); + const [agentMode, setAgentMode] = useState(""); const [permissionMode, setPermissionMode] = useState("default"); const [model, setModel] = useState(""); const [variant, setVariant] = useState(""); - const [agentVersion, setAgentVersion] = useState(""); const [sessionId, setSessionId] = useState("demo-session"); - const [sessionInfo, setSessionInfo] = useState<{ healthy: boolean; agentSessionId?: string } | null>(null); const [sessionError, setSessionError] = useState(null); const [message, setMessage] = useState(""); @@ -218,6 +211,10 @@ export default function App() { const logIdRef = useRef(1); const [copiedLogId, setCopiedLogId] = useState(null); + const [debugTab, setDebugTab] = useState("log"); + + const messagesEndRef = useRef(null); + const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { const next = [entry, ...prev]; @@ -303,7 +300,6 @@ export default function App() { const disconnect = () => { setConnected(false); - setSessionInfo(null); setSessionError(null); setEvents([]); setOffset(0); @@ -316,7 +312,14 @@ export default function App() { const refreshAgents = async () => { try { const data = await apiFetch(`${API_PREFIX}/agents`); - setAgents((data as { agents?: AgentInfo[] })?.agents ?? []); + const agentList = (data as { agents?: AgentInfo[] })?.agents ?? []; + setAgents(agentList); + // Auto-load modes for installed agents + for (const agent of agentList) { + if (agent.installed) { + loadModes(agent.id); + } + } } catch (error) { setConnectError(error instanceof Error ? error.message : "Unable to refresh agents"); } @@ -339,8 +342,27 @@ export default function App() { const data = await apiFetch(`${API_PREFIX}/agents/${targetId}/modes`); const modes = (data as { modes?: AgentMode[] })?.modes ?? []; setModesByAgent((prev) => ({ ...prev, [targetId]: modes })); + } catch { + // Silently fail - modes are optional + } + }; + + const sendMessage = async () => { + if (!message.trim()) return; + setSessionError(null); + try { + await apiFetch(`${API_PREFIX}/sessions/${sessionId}/messages`, { + method: "POST", + body: { message } + }); + setMessage(""); + + // Auto-start polling if not already + if (!polling && streamMode === "poll") { + startPolling(); + } } catch (error) { - setConnectError(error instanceof Error ? error.message : "Unable to load modes"); + setSessionError(error instanceof Error ? error.message : "Unable to send message"); } }; @@ -352,33 +374,13 @@ export default function App() { if (permissionMode) body.permissionMode = permissionMode; if (model) body.model = model; if (variant) body.variant = variant; - if (agentVersion) body.agentVersion = agentVersion; - const data = await apiFetch(`${API_PREFIX}/sessions/${sessionId}`, { + + await apiFetch(`${API_PREFIX}/sessions/${sessionId}`, { method: "POST", body }); - const response = data as { healthy?: boolean; agentSessionId?: string }; - setSessionInfo({ healthy: Boolean(response.healthy), agentSessionId: response.agentSessionId }); - setEvents([]); - setOffset(0); - offsetRef.current = 0; - setEventError(null); } catch (error) { setSessionError(error instanceof Error ? error.message : "Unable to create session"); - setSessionInfo(null); - } - }; - - const sendMessage = async () => { - if (!message.trim()) return; - try { - await apiFetch(`${API_PREFIX}/sessions/${sessionId}/messages`, { - method: "POST", - body: { message } - }); - setMessage(""); - } catch (error) { - setEventError(error instanceof Error ? error.message : "Unable to send message"); } }; @@ -552,10 +554,16 @@ export default function App() { .filter((request) => !permissionStatus[request.id]); }, [events, permissionStatus]); - const transcriptEvents = useMemo(() => { - return events.filter( - (event): event is UniversalEvent & { data: { message: UniversalMessage } } => "message" in event.data - ); + const transcriptMessages = useMemo(() => { + return events + .filter((event): event is UniversalEvent & { data: { message: UniversalMessage } } => "message" in event.data) + .map((event) => ({ + id: event.id, + role: event.data.message?.role ?? "assistant", + content: event.data.message?.content ?? "", + timestamp: event.timestamp + })) + .filter((msg) => msg.content); }, [events]); useEffect(() => { @@ -570,526 +578,595 @@ export default function App() { refreshAgents(); }, [connected]); + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [transcriptMessages]); + + // Auto-load modes when agent changes + useEffect(() => { + if (connected && agentId && !modesByAgent[agentId]) { + loadModes(agentId); + } + }, [connected, agentId]); + + // Set default mode when modes are loaded + useEffect(() => { + const modes = modesByAgent[agentId]; + if (modes && modes.length > 0 && !agentMode) { + setAgentMode(modes[0].id); + } + }, [modesByAgent, agentId]); + const availableAgents = agents.length ? agents.map((agent) => agent.id) : defaultAgents; + const currentAgent = agents.find((a) => a.id === agentId); const activeModes = modesByAgent[agentId] ?? []; + const pendingApprovals = questionRequests.length + permissionRequests.length; - return ( -
-
-
- - Sandbox Agent Console -
-
- - - {connected ? "Connected" : "Disconnected"} - - {connected && ( - - )} -
-
+ const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; - {!connected ? ( -
-
-
Bring the agent fleet online.
-
- Point this console at a running sandbox-agent, then manage sessions, messages, and approvals in - one place. + const toggleStream = () => { + if (polling) { + stopPolling(); + } else if (streamMode === "poll") { + startPolling(); + } else { + startSse(); + } + }; + + if (!connected) { + return ( +
+
+
+
SA
+ Sandbox Agent +
+
+
+ + Disconnected
-
- sandbox-agent --host 0.0.0.0 --port 2468 --token <token> --cors-allow-origin - http://localhost:5173 --cors-allow-method GET --cors-allow-method POST --cors-allow-header Authorization - --cors-allow-header Content-Type +
+
+ +
+
+
+
SA
+

Sandbox Agent

+

+ Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes. +

-
- CORS required for browser access - Token optional with --no-token - HTTP API under /v1 -
-
- If you see a network or CORS error, make sure CORS flags are enabled in the daemon CLI. -
-
-
-
- - - Connect - -
-
+ +
+
Connect to Daemon
+ + {connectError && ( +
{connectError}
+ )} + + - {connectError && ( -
- Connection failed: {connectError} -
If this is a CORS error, enable CORS flags on the daemon.
-
- )} - + +

+ Start the daemon with CORS enabled for browser access:
+ sandbox-agent --cors-allow-origin http://localhost:5173 +

-
+
- ) : ( -
-
-
- - - Agents - -
+ ); + } + + return ( +
+
+
+
SA
+ Sandbox Agent +
+
+ {endpoint} + +
+
+ +
+ {/* Chat Panel - Left */} +
+
+
+ + Session + setSessionId(e.target.value)} + placeholder="session-id" + /> +
-
- {agents.length === 0 &&
No agents reported yet. Refresh when ready.
} -
+ {polling && ( + Live + )} +
+ +
+ {transcriptMessages.length === 0 && !sessionError ? ( +
+ +
Ready to Chat
+

+ Send a message to start a conversation with the agent. +

+
+ ) : ( +
+ {transcriptMessages.map((msg) => ( +
+
+ {msg.role === "user" ? "U" : "AI"} +
+
+ {msg.content} +
+
+ ))} + {sessionError && ( +
+ {sessionError} +
+ )} +
+
+ )} +
+ + {/* Input Area */} +
+
+