diff --git a/CLAUDE.md b/CLAUDE.md index 4ceb0f6..866a3f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,7 @@ - `docs/cli.mdx` - `docs/quickstart.mdx` - `docs/sdk-overview.mdx` + - `docs/react-components.mdx` - `docs/session-persistence.mdx` - `docs/deploy/local.mdx` - `docs/deploy/cloudflare.mdx` diff --git a/docs/docs.json b/docs/docs.json index 8f7a2ec..2d57276 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,6 +51,7 @@ "pages": [ "quickstart", "sdk-overview", + "react-components", { "group": "Deploy", "icon": "server", @@ -79,7 +80,7 @@ }, { "group": "System", - "pages": ["file-system"] + "pages": ["file-system", "processes"] }, { "group": "Orchestration", diff --git a/docs/inspector.mdx b/docs/inspector.mdx index f3b3dc6..06318b2 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -34,9 +34,18 @@ console.log(url); - Event JSON inspector - Prompt testing - Request/response debugging +- Process management (create, stop, kill, delete, view logs) +- Interactive PTY terminal for tty processes +- One-shot command execution ## When to use - Development: validate session behavior quickly - Debugging: inspect raw event payloads - Integration work: compare UI behavior with SDK/API calls + +## Process terminal + +The Inspector includes an embedded Ghostty-based terminal for interactive tty +processes. The UI uses the SDK's high-level `connectProcessTerminal(...)` +wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component. diff --git a/docs/observability.mdx b/docs/observability.mdx index 770fe8b..5b5751b 100644 --- a/docs/observability.mdx +++ b/docs/observability.mdx @@ -1,7 +1,7 @@ --- title: "Observability" description: "Track session activity with OpenTelemetry." -icon: "terminal" +icon: "chart-line" --- Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend. diff --git a/docs/openapi.json b/docs/openapi.json index d6272b7..2df2bba 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.2.1" + "version": "0.2.2" }, "servers": [ { diff --git a/docs/processes.mdx b/docs/processes.mdx new file mode 100644 index 0000000..45c246c --- /dev/null +++ b/docs/processes.mdx @@ -0,0 +1,258 @@ +--- +title: "Processes" +description: "Run commands and manage long-lived processes inside the sandbox." +sidebarTitle: "Processes" +icon: "terminal" +--- + +The process API supports: + +- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code +- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes +- **Log streaming** — fetch buffered logs or follow live output via SSE +- **Terminals** — full PTY support with bidirectional WebSocket I/O +- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime + +## Run a command + +Execute a command to completion and get its output. + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", +}); + +const result = await sdk.runProcess({ + command: "ls", + args: ["-la", "/workspace"], +}); + +console.log(result.exitCode); // 0 +console.log(result.stdout); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"ls","args":["-la","/workspace"]}' +``` + + +You can set a timeout and cap output size: + + +```ts TypeScript +const result = await sdk.runProcess({ + command: "make", + args: ["build"], + timeoutMs: 60000, + maxOutputBytes: 1048576, +}); + +if (result.timedOut) { + console.log("Build timed out"); +} +if (result.stdoutTruncated) { + console.log("Output was truncated"); +} +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"make","args":["build"],"timeoutMs":60000,"maxOutputBytes":1048576}' +``` + + +## Managed processes + +Create a long-lived process that you can interact with, monitor, and stop later. + +### Create + + +```ts TypeScript +const proc = await sdk.createProcess({ + command: "node", + args: ["server.js"], + cwd: "/workspace", +}); + +console.log(proc.id, proc.pid); // proc_1, 12345 +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes" \ + -H "Content-Type: application/json" \ + -d '{"command":"node","args":["server.js"],"cwd":"/workspace"}' +``` + + +### List and get + + +```ts TypeScript +const { processes } = await sdk.listProcesses(); + +for (const p of processes) { + console.log(p.id, p.command, p.status); +} + +const proc = await sdk.getProcess("proc_1"); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/processes" + +curl "http://127.0.0.1:2468/v1/processes/proc_1" +``` + + +### Stop, kill, and delete + + +```ts TypeScript +// SIGTERM with optional wait +await sdk.stopProcess("proc_1", { waitMs: 5000 }); + +// SIGKILL +await sdk.killProcess("proc_1", { waitMs: 1000 }); + +// Remove exited process record +await sdk.deleteProcess("proc_1"); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/stop?waitMs=5000" + +curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/kill?waitMs=1000" + +curl -X DELETE "http://127.0.0.1:2468/v1/processes/proc_1" +``` + + +## Logs + +### Fetch buffered logs + + +```ts TypeScript +const logs = await sdk.getProcessLogs("proc_1", { + tail: 50, + stream: "combined", +}); + +for (const entry of logs.entries) { + console.log(entry.stream, atob(entry.data)); +} +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined" +``` + + +### Follow logs via SSE + +Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives. + +```ts TypeScript +const sub = await sdk.followProcessLogs("proc_1", (entry) => { + console.log(entry.stream, atob(entry.data)); +}); + +// Later, stop following +sub.close(); +await sub.closed; +``` + +## Terminals + +Create a process with `tty: true` to allocate a pseudo-terminal, then connect via WebSocket for full bidirectional I/O. + +```ts TypeScript +const proc = await sdk.createProcess({ + command: "bash", + tty: true, +}); +``` + +### Write input + + +```ts TypeScript +await sdk.sendProcessInput("proc_1", { + data: "echo hello\n", + encoding: "utf8", +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/input" \ + -H "Content-Type: application/json" \ + -d '{"data":"echo hello\n","encoding":"utf8"}' +``` + + +### Connect to a terminal + +Use `ProcessTerminalSession` unless you need direct frame access. + +```ts TypeScript +const terminal = sdk.connectProcessTerminal("proc_1"); + +terminal.onReady(() => { + terminal.resize({ cols: 120, rows: 40 }); + terminal.sendInput("ls\n"); +}); + +terminal.onData((bytes) => { + process.stdout.write(new TextDecoder().decode(bytes)); +}); + +terminal.onExit((status) => { + console.log("exit:", status.exitCode); +}); + +terminal.onError((error) => { + console.error(error instanceof Error ? error.message : error.message); +}); + +terminal.onClose(() => { + console.log("terminal closed"); +}); +``` + +Since the browser WebSocket API cannot send custom headers, the endpoint accepts an `access_token` query parameter for authentication. The SDK handles this automatically. + +### Browser terminal emulators + +The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a drop-in React terminal, see [React Components](/react-components). + +## Configuration + +Adjust runtime limits like max concurrent processes, timeouts, and buffer sizes. + + +```ts TypeScript +const config = await sdk.getProcessConfig(); +console.log(config); + +await sdk.setProcessConfig({ + ...config, + maxConcurrentProcesses: 32, + defaultRunTimeoutMs: 60000, +}); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/processes/config" + +curl -X POST "http://127.0.0.1:2468/v1/processes/config" \ + -H "Content-Type: application/json" \ + -d '{"maxConcurrentProcesses":32,"defaultRunTimeoutMs":60000,"maxRunTimeoutMs":300000,"maxOutputBytes":1048576,"maxLogBytesPerProcess":10485760,"maxInputBytesPerRequest":65536}' +``` + diff --git a/docs/react-components.mdx b/docs/react-components.mdx new file mode 100644 index 0000000..e7298d0 --- /dev/null +++ b/docs/react-components.mdx @@ -0,0 +1,103 @@ +--- +title: "React Components" +description: "Drop-in React components for Sandbox Agent frontends." +icon: "react" +--- + +`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. + +## Install + +```bash +npm install @sandbox-agent/react@0.2.x +``` + +## Full example + +This example connects to a running Sandbox Agent server, starts a tty shell, renders `ProcessTerminal`, and cleans up the process when the component unmounts. + +```tsx TerminalPane.tsx expandable highlight={5,32-36,71} +"use client"; + +import { useEffect, useState } from "react"; +import { SandboxAgent } from "sandbox-agent"; +import { ProcessTerminal } from "@sandbox-agent/react"; + +export default function TerminalPane() { + const [client, setClient] = useState(null); + const [processId, setProcessId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let sdk: SandboxAgent | null = null; + let createdProcessId: string | null = null; + + const cleanup = async () => { + if (!sdk || !createdProcessId) { + return; + } + + await sdk.killProcess(createdProcessId, { waitMs: 1_000 }).catch(() => {}); + await sdk.deleteProcess(createdProcessId).catch(() => {}); + }; + + const start = async () => { + try { + sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); + + const process = await sdk.createProcess({ + command: "sh", + interactive: true, + tty: true, + }); + + if (cancelled) { + createdProcessId = process.id; + await cleanup(); + await sdk.dispose(); + return; + } + + createdProcessId = process.id; + setClient(sdk); + setProcessId(process.id); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to start terminal."; + setError(message); + } + }; + + void start(); + + return () => { + cancelled = true; + void cleanup(); + void sdk?.dispose(); + }; + }, []); + + if (error) { + return
{error}
; + } + + if (!client || !processId) { + return
Starting terminal...
; + } + + return ; +} +``` + +## Component + +`ProcessTerminal` attaches to a running tty process. + +- `client`: a `SandboxAgent` client +- `processId`: the process to attach to +- `height`, `style`, `terminalStyle`: optional layout overrides +- `onExit`, `onError`: optional lifecycle callbacks + +See [Processes](/processes) for the lower-level terminal APIs. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index ffb1a6b..627e440 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -29,6 +29,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x ``` +## Optional React components + +```bash +npm install @sandbox-agent/react@0.2.x +``` + ## Create a client ```ts @@ -206,4 +212,3 @@ Parameters: - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls - `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` - diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 4ae817d..c4515dc 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,26 +1,4 @@ # Frontend Instructions -## Inspector Architecture - -- Inspector source is `frontend/packages/inspector/`. -- `/ui/` must use ACP over HTTP (`/v2/rpc`) for session/prompt traffic. -- Primary flow: - - `initialize` - - `session/new` - - `session/prompt` - - `session/update` over SSE -- Keep backend/protocol changes in client bindings; avoid unnecessary full UI rewrites. - -## Testing - -Run inspector checks after transport or chat-flow changes: -```bash -pnpm --filter @sandbox-agent/inspector test -pnpm --filter @sandbox-agent/inspector test:agent-browser -``` - -## Docs Sync - -- Update `docs/inspector.mdx` when `/ui/` behavior changes. -- Update `docs/sdks/typescript.mdx` when inspector SDK bindings or ACP transport behavior changes. - +- When the user asks for UI changes, capture screenshots of the updated UI after implementation and verification. +- At the end, offer to open those screenshots for the user and provide absolute filesystem paths to the screenshot files. diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index de98dc8..aeec796 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -2648,6 +2648,350 @@ flex-shrink: 0; } + /* ── Process form buttons ── */ + .process-run-form .button.primary, + .process-create-form .button.primary { + width: auto; + } + + .process-detail > .button { + align-self: flex-start; + } + + /* ── Run Once tab ── */ + .process-run-container { + display: flex; + flex-direction: column; + gap: 16px; + } + + .process-run-form { + display: flex; + flex-direction: column; + gap: 10px; + } + + .process-run-row { + display: flex; + gap: 10px; + } + + .process-run-field { + display: flex; + flex-direction: column; + gap: 4px; + } + + .process-run-field-grow { + flex: 1; + min-width: 0; + } + + .process-run-field .setup-input { + width: 100%; + } + + .process-run-field textarea.setup-input { + resize: vertical; + min-height: 42px; + } + + .process-advanced-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--muted); + font-size: 11px; + cursor: pointer; + padding: 2px 0; + align-self: flex-start; + } + + .process-advanced-toggle:hover { + color: var(--text-secondary); + } + + .process-run-result { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + } + + .process-run-result-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface); + } + + .process-run-output { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + } + + .process-run-output-section { + display: flex; + flex-direction: column; + min-width: 0; + } + + .process-run-output-section + .process-run-output-section { + border-left: 1px solid var(--border); + } + + .process-run-output-label { + padding: 6px 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--muted); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface-2); + } + + .process-run-output-section .process-log-block { + border: none; + border-radius: 0; + min-height: 80px; + } + + /* ── Processes tab ── */ + .processes-container { + display: flex; + flex-direction: column; + gap: 20px; + } + + .processes-section { + display: flex; + flex-direction: column; + gap: 8px; + } + + .processes-section-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 2px 0; + align-self: flex-start; + } + + .processes-section-toggle:hover { + color: var(--accent); + } + + .processes-section-label { + font-size: 12px; + font-weight: 600; + color: var(--text); + } + + .processes-list-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .process-create-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + } + + .process-checkbox-row { + display: flex; + flex-wrap: wrap; + gap: 14px; + } + + .process-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + } + + .process-checkbox input { + margin: 0; + } + + /* Process list items */ + .process-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .process-list-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition); + } + + .process-list-item:hover { + background: var(--surface); + } + + .process-list-item.selected { + background: var(--surface); + } + + .process-list-item-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .process-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); + } + + .process-status-dot.running { + background: var(--success); + } + + .process-status-dot.exited { + background: var(--muted); + } + + .process-list-item-cmd { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + .process-list-item-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: var(--muted); + padding-left: 14px; + } + + .process-list-item-id { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + opacity: 0.7; + } + + .process-list-item-actions { + display: flex; + gap: 4px; + padding-left: 14px; + margin-top: 2px; + } + + .process-list-item-actions .button { + padding: 4px 8px; + font-size: 11px; + } + + /* Process detail panel */ + .process-detail { + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 10px; + } + + .process-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .process-detail-cmd { + font-size: 12px; + color: var(--text); + word-break: break-word; + } + + .process-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + font-size: 11px; + color: var(--muted); + } + + .process-detail-logs { + display: flex; + flex-direction: column; + gap: 6px; + } + + .process-detail-logs-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .process-detail-logs-header .button { + padding: 4px 8px; + font-size: 11px; + } + + .process-terminal-empty { + margin-top: 4px; + padding: 10px 12px; + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: var(--radius-sm); + color: var(--muted); + font-size: 11px; + } + + /* Log block (shared) */ + .process-log-block { + margin: 0; + min-height: 80px; + max-height: 280px; + overflow: auto; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 9, 11, 0.95); + color: #e4e4e7; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + } + .pill { display: inline-flex; align-items: center; @@ -3026,6 +3370,26 @@ flex-shrink: 0; } + @media (max-width: 900px) { + .process-run-row { + flex-direction: column; + } + + .process-run-output { + grid-template-columns: 1fr; + } + + .process-run-output-section + .process-run-output-section { + border-left: none; + border-top: 1px solid var(--border); + } + + .process-terminal-meta { + flex-direction: column; + align-items: flex-start; + } + } + /* Scrollbar - match landing page */ * { scrollbar-width: thin; diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json index 119b8ce..9671ecb 100644 --- a/frontend/packages/inspector/package.json +++ b/frontend/packages/inspector/package.json @@ -6,12 +6,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vite build", + "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build", "preview": "vite preview", - "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && tsc --noEmit", - "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vitest run" + "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit", + "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run" }, "devDependencies": { + "@sandbox-agent/react": "workspace:*", "sandbox-agent": "workspace:*", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx index f40c166..b15b59a 100644 --- a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx +++ b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx @@ -1,15 +1,17 @@ -import { ChevronLeft, ChevronRight, Cloud, PlayCircle, Server, Terminal, Wrench } from "lucide-react"; +import { ChevronLeft, ChevronRight, Cloud, Play, PlayCircle, Server, Terminal, Wrench } from "lucide-react"; import type { AgentInfo, SandboxAgent, SessionEvent } from "sandbox-agent"; type AgentModeInfo = { id: string; name: string; description: string }; import AgentsTab from "./AgentsTab"; import EventsTab from "./EventsTab"; import McpTab from "./McpTab"; +import ProcessesTab from "./ProcessesTab"; +import ProcessRunTab from "./ProcessRunTab"; import SkillsTab from "./SkillsTab"; import RequestLogTab from "./RequestLogTab"; import type { RequestLog } from "../../types/requestLog"; -export type DebugTab = "log" | "events" | "agents" | "mcp" | "skills"; +export type DebugTab = "log" | "events" | "agents" | "mcp" | "skills" | "processes" | "run-process"; const DebugPanel = ({ debugTab, @@ -81,6 +83,14 @@ const DebugPanel = ({ MCP + +