This commit is contained in:
NathanFlurry 2026-02-11 14:47:41 +00:00
parent 70287ec471
commit e72eb9f611
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
264 changed files with 18559 additions and 51021 deletions

View file

@ -1,150 +1,26 @@
## Frontend Style Guide
# Frontend Instructions
Examples should follow these design conventions:
## Inspector Architecture
**Color Palette (Dark Theme)**
- Primary accent: `#ff4f00` (orange) for interactive elements and highlights
- Background: `#000000` (main), `#1c1c1e` (cards/containers)
- Borders: `#2c2c2e`
- Input backgrounds: `#2c2c2e` with border `#3a3a3c`
- Text: `#ffffff` (primary), `#8e8e93` (secondary/muted)
- Success: `#30d158` (green)
- Warning: `#ff4f00` (orange)
- Danger: `#ff3b30` (red)
- Purple: `#bf5af2` (for special states like rollback)
- 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.
**Typography**
- UI: System fonts (`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif`)
- Code: `ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace`
- Sizes: 14-16px body, 12-13px labels, large numbers 48-72px
## Testing
**Sizing & Spacing**
- Border radius: 8px (cards/containers/buttons), 6px (inputs/badges)
- Section padding: 20-24px
- Gap between items: 12px
- Transitions: 200ms ease for all interactive states
Run inspector checks after transport or chat-flow changes:
```bash
pnpm --filter @sandbox-agent/inspector test
pnpm --filter @sandbox-agent/inspector test:agent-browser
```
**Button Styles**
- Padding: 12px 20px
- Border: none
- Border radius: 8px
- Font size: 14px, weight 600
- Hover: none (no hover state)
- Disabled: 50% opacity, `cursor: not-allowed`
**CSS Approach**
- Plain CSS in `<style>` tag within index.html (no preprocessors or Tailwind)
- Class-based selectors with state modifiers (`.active`, `.complete`, `.running`)
- Focus states use primary accent color (`#ff4f00`) for borders with subtle box-shadow
**Spacing System**
- Base unit: 4px
- Scale: 4px, 8px, 12px, 16px, 20px, 24px, 32px, 48px
- Component internal padding: 12-16px
- Section/card padding: 20px
- Card header padding: 16px 20px
- Gap between related items: 8-12px
- Gap between sections: 24-32px
- Margin between major blocks: 32px
**Iconography**
- Icon library: [Lucide](https://lucide.dev/) (React: `lucide-react`)
- Standard sizes: 16px (inline/small), 20px (buttons/UI), 24px (standalone/headers)
- Icon color: inherit from parent text color, or use `currentColor`
- Icon-only buttons must include `aria-label` for accessibility
- Stroke width: 2px (default), 1.5px for smaller icons
**Component Patterns**
*Buttons*
- Primary: `#ff4f00` background, white text
- Secondary: `#2c2c2e` background, white text
- Ghost: transparent background, `#ff4f00` text
- Danger: `#ff3b30` background, white text
- Success: `#30d158` background, white text
- Disabled: 50% opacity, `cursor: not-allowed`
*Form Inputs*
- Background: `#2c2c2e`
- Border: 1px solid `#3a3a3c`
- Border radius: 8px
- Padding: 12px 16px
- Focus: border-color `#ff4f00`, box-shadow `0 0 0 3px rgba(255, 79, 0, 0.2)`
- Placeholder text: `#6e6e73`
*Cards/Containers*
- Background: `#1c1c1e`
- Border: 1px solid `#2c2c2e`
- Border radius: 8px
- Padding: 20px
- Box shadow: `0 1px 3px rgba(0, 0, 0, 0.3)`
- Header style (when applicable):
- Background: `#2c2c2e`
- Padding: 16px 20px
- Font size: 18px, weight 600
- Border bottom: 1px solid `#2c2c2e`
- Border radius: 8px 8px 0 0 (top corners only)
- Negative margin to align with card edges: `-20px -20px 20px -20px`
*Modals/Overlays*
- Backdrop: `rgba(0, 0, 0, 0.75)`
- Modal background: `#1c1c1e`
- Border radius: 8px
- Max-width: 480px (small), 640px (medium), 800px (large)
- Padding: 24px
- Close button: top-right, 8px from edges
*Lists*
- Item padding: 12px 16px
- Dividers: 1px solid `#2c2c2e`
- Hover background: `#2c2c2e`
- Selected/active background: `rgba(255, 79, 0, 0.15)`
*Badges/Tags*
- Padding: 4px 8px
- Border radius: 6px
- Font size: 12px
- Font weight: 500
*Tabs*
- Container: `border-bottom: 1px solid #2c2c2e`, flex-wrap for overflow
- Tab: `padding: 12px 16px`, no background, `border-radius: 0`
- Tab border: `border-bottom: 2px solid transparent`, `margin-bottom: -1px`
- Tab text: `#8e8e93` (muted), font-weight 600, font-size 14px
- Active tab: `color: #ffffff`, `border-bottom-color: #ff4f00`
- Hover: none (no hover state)
- Transition: `color 200ms ease, border-color 200ms ease`
**UI States**
*Loading States*
- Spinner: 20px for inline, 32px for page-level
- Skeleton placeholders: `#2c2c2e` background with subtle pulse animation
- Loading text: "Loading..." in muted color
- Button loading: show spinner, disable interaction, keep button width stable
*Empty States*
- Center content vertically and horizontally
- Icon: 48px, muted color (`#6e6e73`)
- Heading: 18px, primary text color
- Description: 14px, muted color
- Optional action button below description
*Error States*
- Inline errors: `#ff3b30` text below input, 12px font size
- Error banners: `#ff3b30` left border (4px), `rgba(255, 59, 48, 0.1)` background
- Form validation: highlight input border in `#ff3b30`
- Error icon: Lucide `AlertCircle` or `XCircle`
*Disabled States*
- Opacity: 50%
- Cursor: `not-allowed`
- No hover/focus effects
- Preserve layout (don't collapse or hide)
*Success States*
- Color: `#30d158`
- Icon: Lucide `CheckCircle` or `Check`
- Toast/banner: `rgba(48, 209, 88, 0.1)` background with green left border
## Docs Sync
- Update `docs/inspector.mdx` when `/ui/` behavior changes.
- Update `docs/sdks/typescript.mdx` when inspector SDK bindings or ACP transport behavior changes.

View file

@ -1168,26 +1168,6 @@
width: auto;
}
.mock-agent-hint {
margin-top: 16px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius);
font-size: 12px;
color: var(--text-secondary);
max-width: 320px;
line-height: 1.5;
}
.mock-agent-hint code {
background: var(--border-2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
font-size: 11px;
}
.empty-state-menu-wrapper {
position: relative;
}

View file

@ -1,7 +1,7 @@
import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../App";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "../types/legacyApi";
export type SessionConfig = {
model: string;
@ -14,8 +14,7 @@ const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
amp: "Amp"
};
const validateServerJson = (json: string): string | null => {

View file

@ -45,7 +45,7 @@ const badges = [
type BadgeItem = (typeof badges)[number];
const getEnabled = (featureCoverage: FeatureCoverageView, key: BadgeItem["key"]) =>
Boolean((featureCoverage as Record<string, boolean | undefined>)[key]);
Boolean((featureCoverage as unknown as Record<string, boolean | undefined>)[key]);
const FeatureCoverageBadges = ({ featureCoverage }: { featureCoverage: FeatureCoverageView }) => {
return (

View file

@ -37,7 +37,9 @@ const ChatMessages = ({
const isInProgress = item.status === "in_progress";
const isFailed = item.status === "failed";
const messageClass = getMessageClass(item);
const statusLabel = item.status !== "completed" ? item.status.replace("_", " ") : "";
const statusValue = item.status ?? "";
const statusLabel =
statusValue && statusValue !== "completed" ? statusValue.replace("_", " ") : "";
const kindLabel = item.kind.replace("_", " ");
return (

View file

@ -1,7 +1,14 @@
import { MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../../App";
import type {
AgentInfo,
AgentModelInfo,
AgentModeInfo,
PermissionEventData,
QuestionEventData,
SkillSource
} from "../../types/legacyApi";
import ApprovalsTab from "../debug/ApprovalsTab";
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
@ -175,11 +182,6 @@ const ChatPanel = ({
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
{agentLabel === "Mock" && (
<div className="mock-agent-hint">
The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands.
</div>
)}
</div>
) : (
<ChatMessages

View file

@ -1,4 +1,4 @@
import type { UniversalItem } from "sandbox-agent";
import type { UniversalItem } from "../../types/legacyApi";
export const getMessageClass = (item: UniversalItem) => {
if (item.kind === "tool_call" || item.kind === "tool_result") return "tool";

View file

@ -1,4 +1,4 @@
import type { ContentPart } from "sandbox-agent";
import type { ContentPart } from "../../types/legacyApi";
import { formatJson } from "../../utils/format";
const renderContentPart = (part: ContentPart, index: number) => {

View file

@ -1,4 +1,4 @@
import type { UniversalItem } from "sandbox-agent";
import type { UniversalItem } from "../../types/legacyApi";
export type TimelineEntry = {
id: string;

View file

@ -1,6 +1,6 @@
import { Download, Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import type { AgentInfo, AgentModeInfo } from "sandbox-agent";
import type { AgentInfo, AgentModeInfo } from "../../types/legacyApi";
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents";

View file

@ -1,5 +1,5 @@
import { HelpCircle, Shield } from "lucide-react";
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
import type { PermissionEventData, QuestionEventData } from "../../types/legacyApi";
import { formatJson } from "../../utils/format";
const ApprovalsTab = ({

View file

@ -1,5 +1,5 @@
import { Cloud, PlayCircle, Terminal } from "lucide-react";
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "sandbox-agent";
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "../../types/legacyApi";
import AgentsTab from "./AgentsTab";
import EventsTab from "./EventsTab";
import RequestLogTab from "./RequestLogTab";

View file

@ -1,6 +1,6 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import type { UniversalEvent } from "sandbox-agent";
import type { UniversalEvent } from "../../types/legacyApi";
import { formatJson, formatTime } from "../../utils/format";
import { getEventCategory, getEventClass, getEventIcon, getEventKey, getEventType } from "./eventUtils";

View file

@ -1,6 +1,8 @@
import { Clipboard } from "lucide-react";
import { ChevronDown, ChevronRight, Clipboard } from "lucide-react";
import { useState } from "react";
import type { RequestLog } from "../../types/requestLog";
import { formatJson } from "../../utils/format";
const RequestLogTab = ({
requestLog,
@ -13,6 +15,12 @@ const RequestLogTab = ({
onClear: () => void;
onCopy: (entry: RequestLog) => void;
}) => {
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const toggleExpanded = (id: number) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
@ -25,28 +33,94 @@ const RequestLogTab = ({
{requestLog.length === 0 ? (
<div className="card-meta">No requests logged yet.</div>
) : (
requestLog.map((entry) => (
<div key={entry.id} className="log-item">
<span className="log-method">{entry.method}</span>
<span className="log-url text-truncate">{entry.url}</span>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</span>
<div className="log-meta">
<span>
{entry.time}
{entry.error && ` - ${entry.error}`}
</span>
<button className="copy-button" onClick={() => onCopy(entry)}>
<Clipboard />
{copiedLogId === entry.id ? "Copied" : "curl"}
</button>
</div>
</div>
))
<div className="event-list">
{requestLog.map((entry) => {
const isExpanded = expanded[entry.id] ?? false;
const hasDetails = entry.headers || entry.body || entry.responseBody;
return (
<div key={entry.id} className={`event-item ${isExpanded ? "expanded" : "collapsed"}`}>
<button
className="event-summary"
type="button"
onClick={() => hasDetails && toggleExpanded(entry.id)}
title={hasDetails ? (isExpanded ? "Collapse" : "Expand") : undefined}
style={{ cursor: hasDetails ? "pointer" : "default" }}
>
<div className="event-summary-main" style={{ flex: 1 }}>
<div className="event-title-row">
<span className="log-method">{entry.method}</span>
<span className="log-url text-truncate" style={{ flex: 1 }}>{entry.url}</span>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</span>
</div>
<div className="event-id">
{entry.time}
{entry.error && ` - ${entry.error}`}
</div>
</div>
<span
className="copy-button"
onClick={(e) => {
e.stopPropagation();
onCopy(entry);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onCopy(entry);
}
}}
>
<Clipboard size={14} />
{copiedLogId === entry.id ? "Copied" : "curl"}
</span>
{hasDetails && (
<span className="event-chevron">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
)}
</button>
{isExpanded && (
<div className="event-payload" style={{ padding: "8px 12px" }}>
{entry.headers && Object.keys(entry.headers).length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="part-title">Request Headers</div>
<pre className="code-block">{Object.entries(entry.headers).map(([k, v]) => `${k}: ${v}`).join("\n")}</pre>
</div>
)}
{entry.body && (
<div style={{ marginBottom: 8 }}>
<div className="part-title">Request Body</div>
<pre className="code-block">{formatJsonSafe(entry.body)}</pre>
</div>
)}
{entry.responseBody && (
<div>
<div className="part-title">Response Body</div>
<pre className="code-block">{formatJsonSafe(entry.responseBody)}</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</>
);
};
const formatJsonSafe = (text: string): string => {
try {
const parsed = JSON.parse(text);
return formatJson(parsed);
} catch {
return text;
}
};
export default RequestLogTab;

View file

@ -5,6 +5,7 @@ import {
CheckCircle,
FileDiff,
HelpCircle,
Info,
MessageSquare,
PauseCircle,
PlayCircle,
@ -13,7 +14,7 @@ import {
Wrench,
Zap
} from "lucide-react";
import type { UniversalEvent } from "sandbox-agent";
import type { UniversalEvent } from "../../types/legacyApi";
export const getEventType = (event: UniversalEvent) => event.type;
@ -26,10 +27,45 @@ export const getEventClass = (type: string) => type.replace(/\./g, "-");
export const getEventIcon = (type: string) => {
switch (type) {
// ACP session update events
case "acp.agent_message_chunk":
return MessageSquare;
case "acp.user_message_chunk":
return MessageSquare;
case "acp.agent_thought_chunk":
return Brain;
case "acp.tool_call":
return Wrench;
case "acp.tool_call_update":
return Activity;
case "acp.plan":
return FileDiff;
case "acp.session_info_update":
return Info;
case "acp.usage_update":
return Info;
case "acp.current_mode_update":
return Info;
case "acp.config_option_update":
return Info;
case "acp.available_commands_update":
return Terminal;
// Inspector lifecycle events
case "inspector.turn_started":
return PlayCircle;
case "inspector.turn_ended":
return PauseCircle;
case "inspector.user_message":
return MessageSquare;
// Session lifecycle (inspector-emitted)
case "session.started":
return PlayCircle;
case "session.ended":
return PauseCircle;
// Legacy synthetic events
case "turn.started":
return PlayCircle;
case "turn.ended":
@ -40,6 +76,8 @@ export const getEventIcon = (type: string) => {
return Activity;
case "item.completed":
return CheckCircle;
// Approval events
case "question.requested":
return HelpCircle;
case "question.resolved":
@ -48,11 +86,16 @@ export const getEventIcon = (type: string) => {
return Shield;
case "permission.resolved":
return CheckCircle;
// Error events
case "error":
return AlertTriangle;
case "agent.unparsed":
return Brain;
default:
if (type.startsWith("acp.")) return Zap;
if (type.startsWith("inspector.")) return Info;
if (type.startsWith("item.")) return MessageSquare;
if (type.startsWith("session.")) return PlayCircle;
if (type.startsWith("error")) return AlertTriangle;

View file

@ -0,0 +1,790 @@
import {
SandboxAgent,
type PermissionOption,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SandboxAgentAcpClient,
type SandboxAgentConnectOptions,
type SessionNotification,
} from "sandbox-agent";
import type {
AgentInfo,
AgentModelInfo,
AgentModeInfo,
AgentModelsResponse,
AgentModesResponse,
CreateSessionRequest,
EventsQuery,
EventsResponse,
MessageRequest,
PermissionEventData,
PermissionReplyRequest,
QuestionEventData,
QuestionReplyRequest,
SessionInfo,
SessionListResponse,
TurnStreamQuery,
UniversalEvent,
} from "../types/legacyApi";
type PendingPermission = {
request: RequestPermissionRequest;
resolve: (response: RequestPermissionResponse) => void;
autoEndTurnOnResolve?: boolean;
};
type PendingQuestion = {
prompt: string;
options: string[];
autoEndTurnOnResolve?: boolean;
};
type RuntimeSession = {
aliasSessionId: string;
realSessionId: string;
agent: string;
connection: SandboxAgentAcpClient;
events: UniversalEvent[];
nextSequence: number;
listeners: Set<(event: UniversalEvent) => void>;
info: SessionInfo;
pendingPermissions: Map<string, PendingPermission>;
pendingQuestions: Map<string, PendingQuestion>;
};
const TDOO_PERMISSION_MODE =
"TDOO: ACP permission mode preconfiguration is not implemented in inspector compatibility.";
const TDOO_VARIANT =
"TDOO: ACP session variants are not implemented in inspector compatibility.";
const TDOO_SKILLS =
"TDOO: ACP skills source configuration is not implemented in inspector compatibility.";
const TDOO_MODE_DISCOVERY =
"TDOO: ACP mode discovery before session creation is not implemented; returning cached/empty modes.";
const TDOO_MODEL_DISCOVERY =
"TDOO: ACP model discovery before session creation is not implemented; returning cached/empty models.";
export class InspectorLegacyClient {
private readonly base: SandboxAgent;
private readonly sessions = new Map<string, RuntimeSession>();
private readonly aliasByRealSessionId = new Map<string, string>();
private readonly modeCache = new Map<string, AgentModeInfo[]>();
private readonly modelCache = new Map<string, AgentModelsResponse>();
private permissionCounter = 0;
private constructor(base: SandboxAgent) {
this.base = base;
}
static async connect(options: SandboxAgentConnectOptions): Promise<InspectorLegacyClient> {
const base = await SandboxAgent.connect(options);
return new InspectorLegacyClient(base);
}
async getHealth() {
return this.base.getHealth();
}
async listAgents(): Promise<{ agents: AgentInfo[] }> {
const response = await this.base.listAgents();
return {
agents: response.agents.map((agent) => {
const installed =
agent.agent_process_installed &&
(!agent.native_required || agent.native_installed);
return {
id: agent.id,
installed,
credentialsAvailable: true,
version: agent.agent_process_version ?? agent.native_version ?? null,
path: null,
capabilities: {
unstable_methods: agent.capabilities.unstable_methods,
},
native_required: agent.native_required,
native_installed: agent.native_installed,
native_version: agent.native_version,
agent_process_installed: agent.agent_process_installed,
agent_process_source: agent.agent_process_source,
agent_process_version: agent.agent_process_version,
};
}),
};
}
async installAgent(agent: string, request: { reinstall?: boolean } = {}) {
return this.base.installAgent(agent, request);
}
async getAgentModes(agentId: string): Promise<AgentModesResponse> {
const modes = this.modeCache.get(agentId);
if (modes) {
return { modes };
}
console.warn(TDOO_MODE_DISCOVERY);
return { modes: [] };
}
async getAgentModels(agentId: string): Promise<AgentModelsResponse> {
const models = this.modelCache.get(agentId);
if (models) {
return models;
}
console.warn(TDOO_MODEL_DISCOVERY);
return { models: [], defaultModel: null };
}
async createSession(aliasSessionId: string, request: CreateSessionRequest): Promise<void> {
await this.terminateSession(aliasSessionId).catch(() => {
// Ignore if it doesn't exist yet.
});
const acp = await this.base.createAcpClient({
agent: request.agent,
client: {
sessionUpdate: async (notification) => {
this.handleSessionUpdate(notification);
},
requestPermission: async (permissionRequest) => {
return this.handlePermissionRequest(permissionRequest);
},
},
});
await acp.initialize();
const created = await acp.newSession({
cwd: "/",
mcpServers: convertMcpConfig(request.mcp ?? {}),
});
if (created.modes?.availableModes) {
this.modeCache.set(
request.agent,
created.modes.availableModes.map((mode) => ({
id: mode.id,
name: mode.name,
description: mode.description ?? undefined,
})),
);
}
if (created.models?.availableModels) {
this.modelCache.set(request.agent, {
models: created.models.availableModels.map((model) => ({
id: model.modelId,
name: model.name,
description: model.description ?? undefined,
})),
defaultModel: created.models.currentModelId ?? null,
});
}
const runtime: RuntimeSession = {
aliasSessionId,
realSessionId: created.sessionId,
agent: request.agent,
connection: acp,
events: [],
nextSequence: 1,
listeners: new Set(),
info: {
sessionId: aliasSessionId,
agent: request.agent,
eventCount: 0,
ended: false,
model: request.model ?? null,
variant: request.variant ?? null,
permissionMode: request.permissionMode ?? null,
mcp: request.mcp,
skills: request.skills,
},
pendingPermissions: new Map(),
pendingQuestions: new Map(),
};
this.sessions.set(aliasSessionId, runtime);
this.aliasByRealSessionId.set(created.sessionId, aliasSessionId);
if (request.agentMode) {
try {
await acp.setSessionMode({ sessionId: created.sessionId, modeId: request.agentMode });
} catch {
this.emitError(aliasSessionId, `TDOO: Unable to apply mode \"${request.agentMode}\" via ACP.`);
}
}
if (request.model) {
try {
await acp.unstableSetSessionModel({
sessionId: created.sessionId,
modelId: request.model,
});
} catch {
this.emitError(aliasSessionId, `TDOO: Unable to apply model \"${request.model}\" via ACP.`);
}
}
if (request.permissionMode) {
this.emitError(aliasSessionId, TDOO_PERMISSION_MODE);
}
if (request.variant) {
this.emitError(aliasSessionId, TDOO_VARIANT);
}
if (request.skills?.sources && request.skills.sources.length > 0) {
this.emitError(aliasSessionId, TDOO_SKILLS);
}
this.emitEvent(aliasSessionId, "session.started", {
session_id: aliasSessionId,
agent: request.agent,
});
}
async listSessions(): Promise<SessionListResponse> {
const sessions = Array.from(this.sessions.values()).map((session) => {
return {
...session.info,
eventCount: session.events.length,
};
});
return { sessions };
}
async postMessage(sessionId: string, request: MessageRequest): Promise<void> {
const runtime = this.requireActiveSession(sessionId);
const message = request.message.trim();
if (!message) {
return;
}
this.emitEvent(sessionId, "inspector.turn_started", {
session_id: sessionId,
});
this.emitEvent(sessionId, "inspector.user_message", {
session_id: sessionId,
text: message,
});
try {
await runtime.connection.prompt({
sessionId: runtime.realSessionId,
prompt: [{ type: "text", text: message }],
});
} catch (error) {
const detail = error instanceof Error ? error.message : "prompt failed";
this.emitError(sessionId, detail);
throw error;
} finally {
this.emitEvent(sessionId, "inspector.turn_ended", {
session_id: sessionId,
});
}
}
async getEvents(sessionId: string, query: EventsQuery = {}): Promise<EventsResponse> {
const runtime = this.requireSession(sessionId);
const offset = query.offset ?? 0;
const limit = query.limit ?? 200;
const events = runtime.events.filter((event) => event.sequence > offset).slice(0, limit);
return { events };
}
async *streamEvents(
sessionId: string,
query: EventsQuery = {},
signal?: AbortSignal,
): AsyncIterable<UniversalEvent> {
const runtime = this.requireSession(sessionId);
let cursor = query.offset ?? 0;
for (const event of runtime.events) {
if (event.sequence <= cursor) {
continue;
}
cursor = event.sequence;
yield event;
}
const queue: UniversalEvent[] = [];
let wake: (() => void) | null = null;
const listener = (event: UniversalEvent) => {
if (event.sequence <= cursor) {
return;
}
queue.push(event);
if (wake) {
wake();
wake = null;
}
};
runtime.listeners.add(listener);
try {
while (!signal?.aborted) {
if (queue.length === 0) {
await waitForSignalOrEvent(signal, () => {
wake = () => {};
return new Promise<void>((resolve) => {
wake = resolve;
});
});
continue;
}
const next = queue.shift();
if (!next) {
continue;
}
cursor = next.sequence;
yield next;
}
} finally {
runtime.listeners.delete(listener);
}
}
async *streamTurn(
sessionId: string,
request: MessageRequest,
_query?: TurnStreamQuery,
signal?: AbortSignal,
): AsyncIterable<UniversalEvent> {
if (signal?.aborted) {
return;
}
const runtime = this.requireActiveSession(sessionId);
let cursor = runtime.nextSequence - 1;
const queue: UniversalEvent[] = [];
let wake: (() => void) | null = null;
let promptDone = false;
let promptError: unknown = null;
const notify = () => {
if (wake) {
wake();
wake = null;
}
};
const listener = (event: UniversalEvent) => {
if (event.sequence <= cursor) {
return;
}
queue.push(event);
notify();
};
runtime.listeners.add(listener);
const promptPromise = this.postMessage(sessionId, request)
.catch((error) => {
promptError = error;
})
.finally(() => {
promptDone = true;
notify();
});
try {
while (!signal?.aborted) {
if (queue.length === 0) {
if (promptDone) {
break;
}
await waitForSignalOrEvent(signal, () => {
wake = () => {};
return new Promise<void>((resolve) => {
wake = resolve;
});
});
continue;
}
const next = queue.shift();
if (!next) {
continue;
}
cursor = next.sequence;
yield next;
}
} finally {
runtime.listeners.delete(listener);
}
await promptPromise;
if (promptError) {
throw promptError;
}
}
async replyQuestion(
sessionId: string,
questionId: string,
request: QuestionReplyRequest,
): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingQuestions.get(questionId);
if (!pending) {
throw new Error("TDOO: Question request no longer pending.");
}
runtime.pendingQuestions.delete(questionId);
const response = request.answers?.[0]?.[0] ?? null;
const resolved: QuestionEventData & { response?: string | null } = {
question_id: questionId,
status: "resolved",
prompt: pending.prompt,
options: pending.options,
response,
};
this.emitEvent(sessionId, "question.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async rejectQuestion(sessionId: string, questionId: string): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingQuestions.get(questionId);
if (!pending) {
throw new Error("TDOO: Question request no longer pending.");
}
runtime.pendingQuestions.delete(questionId);
const resolved: QuestionEventData & { response?: string | null } = {
question_id: questionId,
status: "resolved",
prompt: pending.prompt,
options: pending.options,
response: null,
};
this.emitEvent(sessionId, "question.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async replyPermission(
sessionId: string,
permissionId: string,
request: PermissionReplyRequest,
): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingPermissions.get(permissionId);
if (!pending) {
throw new Error("TDOO: Permission request no longer pending.");
}
const optionId = selectPermissionOption(pending.request.options, request.reply);
const response: RequestPermissionResponse = optionId
? {
outcome: {
outcome: "selected",
optionId,
},
}
: {
outcome: {
outcome: "cancelled",
},
};
pending.resolve(response);
runtime.pendingPermissions.delete(permissionId);
const action = pending.request.toolCall.title ?? pending.request.toolCall.kind ?? "permission";
const resolved: PermissionEventData = {
permission_id: permissionId,
status: "resolved",
action,
metadata: {
reply: request.reply,
},
};
this.emitEvent(sessionId, "permission.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async terminateSession(sessionId: string): Promise<void> {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
return;
}
this.emitEvent(sessionId, "session.ended", {
reason: "terminated_by_user",
terminated_by: "inspector",
});
runtime.info.ended = true;
for (const pending of runtime.pendingPermissions.values()) {
pending.resolve({
outcome: {
outcome: "cancelled",
},
});
}
runtime.pendingPermissions.clear();
runtime.pendingQuestions.clear();
try {
await runtime.connection.close();
} catch {
// Best-effort close.
}
this.aliasByRealSessionId.delete(runtime.realSessionId);
}
async dispose(): Promise<void> {
for (const sessionId of Array.from(this.sessions.keys())) {
await this.terminateSession(sessionId);
}
await this.base.dispose();
}
private handleSessionUpdate(notification: SessionNotification): void {
const aliasSessionId = this.aliasByRealSessionId.get(notification.sessionId);
if (!aliasSessionId) {
return;
}
const runtime = this.sessions.get(aliasSessionId);
if (!runtime || runtime.info.ended) {
return;
}
const update = notification.update;
// Still handle session_info_update for sidebar metadata
if (update.sessionUpdate === "session_info_update") {
runtime.info.title = update.title ?? runtime.info.title;
runtime.info.updatedAt = update.updatedAt ?? runtime.info.updatedAt;
}
// Emit the raw notification as the event data, using the ACP discriminator as the type
this.emitEvent(aliasSessionId, `acp.${update.sessionUpdate}`, notification);
}
private async handlePermissionRequest(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const aliasSessionId = this.aliasByRealSessionId.get(request.sessionId);
if (!aliasSessionId) {
return {
outcome: {
outcome: "cancelled",
},
};
}
const runtime = this.sessions.get(aliasSessionId);
if (!runtime || runtime.info.ended) {
return {
outcome: {
outcome: "cancelled",
},
};
}
this.permissionCounter += 1;
const permissionId = `permission-${this.permissionCounter}`;
const action = request.toolCall.title ?? request.toolCall.kind ?? "permission";
const pendingEvent: PermissionEventData = {
permission_id: permissionId,
status: "requested",
action,
metadata: request,
};
this.emitEvent(aliasSessionId, "permission.requested", pendingEvent);
return await new Promise<RequestPermissionResponse>((resolve) => {
runtime.pendingPermissions.set(permissionId, { request, resolve });
});
}
private emitError(sessionId: string, message: string): void {
this.emitEvent(sessionId, "error", {
message,
});
}
private emitEvent(sessionId: string, type: string, data: unknown): void {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
return;
}
const event: UniversalEvent = {
event_id: `${sessionId}-${runtime.nextSequence}`,
sequence: runtime.nextSequence,
type,
source: "inspector.acp",
time: new Date().toISOString(),
synthetic: true,
data,
};
runtime.nextSequence += 1;
runtime.events.push(event);
runtime.info.eventCount = runtime.events.length;
for (const listener of runtime.listeners) {
listener(event);
}
}
private requireSession(sessionId: string): RuntimeSession {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
throw new Error(`Session not found: ${sessionId}`);
}
return runtime;
}
private requireActiveSession(sessionId: string): RuntimeSession {
const runtime = this.requireSession(sessionId);
if (runtime.info.ended) {
throw new Error(`Session ended: ${sessionId}`);
}
return runtime;
}
}
const convertMcpConfig = (mcp: Record<string, unknown>) => {
return Object.entries(mcp)
.map(([name, config]) => {
if (!config || typeof config !== "object") {
return null;
}
const value = config as Record<string, unknown>;
const type = value.type;
if (type === "local") {
const commandValue = value.command;
const argsValue = value.args;
let command = "";
let args: string[] = [];
if (Array.isArray(commandValue) && commandValue.length > 0) {
command = String(commandValue[0] ?? "");
args = commandValue.slice(1).map((part) => String(part));
} else if (typeof commandValue === "string") {
command = commandValue;
}
if (Array.isArray(argsValue)) {
args = argsValue.map((part) => String(part));
}
const envObject =
value.env && typeof value.env === "object" ? (value.env as Record<string, unknown>) : {};
const env = Object.entries(envObject).map(([envName, envValue]) => ({
name: envName,
value: String(envValue),
}));
return {
name,
command,
args,
env,
};
}
if (type === "remote") {
const headersObject =
value.headers && typeof value.headers === "object"
? (value.headers as Record<string, unknown>)
: {};
const headers = Object.entries(headersObject).map(([headerName, headerValue]) => ({
name: headerName,
value: String(headerValue),
}));
return {
type: "http" as const,
name,
url: String(value.url ?? ""),
headers,
};
}
return null;
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
};
const selectPermissionOption = (
options: PermissionOption[],
reply: PermissionReplyRequest["reply"],
): string | null => {
const pick = (...kinds: PermissionOption["kind"][]) => {
return options.find((option) => kinds.includes(option.kind))?.optionId ?? null;
};
if (reply === "always") {
return pick("allow_always", "allow_once");
}
if (reply === "once") {
return pick("allow_once", "allow_always");
}
return pick("reject_once", "reject_always");
};
const waitForSignalOrEvent = async (
signal: AbortSignal | undefined,
createWaitPromise: () => Promise<void>,
) => {
if (signal?.aborted) {
return;
}
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) {
return;
}
done = true;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
};
const onAbort = () => finish();
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
createWaitPromise().then(finish).catch(finish);
});
};

View file

@ -1,6 +1,9 @@
import type { AgentCapabilities } from "sandbox-agent";
export type FeatureCoverageView = AgentCapabilities & {
export type FeatureCoverageView = {
unstable_methods?: boolean;
planMode?: boolean;
permissions?: boolean;
questions?: boolean;
toolCalls?: boolean;
toolResults?: boolean;
textMessages?: boolean;
images?: boolean;
@ -15,9 +18,11 @@ export type FeatureCoverageView = AgentCapabilities & {
streamingDeltas?: boolean;
itemStarted?: boolean;
variants?: boolean;
sharedProcess?: boolean;
};
export const emptyFeatureCoverage: FeatureCoverageView = {
unstable_methods: false,
planMode: false,
permissions: false,
questions: false,

View file

@ -0,0 +1,145 @@
export type SkillSourceType = "github" | "local" | "git";
export type SkillSource = {
type: SkillSourceType;
source: string;
skills?: string[];
ref?: string;
subpath?: string;
};
export type CreateSessionRequest = {
agent: string;
agentMode?: string;
permissionMode?: string;
model?: string;
variant?: string;
mcp?: Record<string, unknown>;
skills?: {
sources: SkillSource[];
};
};
export type AgentModeInfo = {
id: string;
name?: string;
description?: string;
};
export type AgentModelInfo = {
id: string;
name?: string;
description?: string;
variants?: string[];
};
export type AgentInfo = {
id: string;
installed: boolean;
credentialsAvailable: boolean;
version?: string | null;
path?: string | null;
capabilities: Record<string, boolean | undefined>;
native_required?: boolean;
native_installed?: boolean;
native_version?: string | null;
agent_process_installed?: boolean;
agent_process_source?: string | null;
agent_process_version?: string | null;
};
export type ContentPart = {
type?: string;
[key: string]: unknown;
};
export type UniversalItem = {
item_id: string;
native_item_id?: string | null;
parent_id?: string | null;
kind: string;
role?: string | null;
content?: ContentPart[];
status?: string | null;
[key: string]: unknown;
};
export type UniversalEvent = {
event_id: string;
sequence: number;
type: string;
source: string;
time: string;
synthetic?: boolean;
data: unknown;
[key: string]: unknown;
};
export type PermissionEventData = {
permission_id: string;
status: "requested" | "resolved";
action: string;
metadata?: unknown;
};
export type QuestionEventData = {
question_id: string;
status: "requested" | "resolved";
prompt: string;
options: string[];
};
export type SessionInfo = {
sessionId: string;
agent: string;
eventCount: number;
ended?: boolean;
model?: string | null;
variant?: string | null;
permissionMode?: string | null;
mcp?: Record<string, unknown>;
skills?: {
sources?: SkillSource[];
};
title?: string | null;
updatedAt?: string | null;
};
export type EventsQuery = {
offset?: number;
limit?: number;
includeRaw?: boolean;
};
export type EventsResponse = {
events: UniversalEvent[];
};
export type SessionListResponse = {
sessions: SessionInfo[];
};
export type AgentModesResponse = {
modes: AgentModeInfo[];
};
export type AgentModelsResponse = {
models: AgentModelInfo[];
defaultModel?: string | null;
};
export type MessageRequest = {
message: string;
};
export type TurnStreamQuery = {
includeRaw?: boolean;
};
export type PermissionReplyRequest = {
reply: "once" | "always" | "reject";
};
export type QuestionReplyRequest = {
answers: string[][];
};

View file

@ -2,8 +2,10 @@ export type RequestLog = {
id: number;
method: string;
url: string;
headers?: Record<string, string>;
body?: string;
status?: number;
responseBody?: string;
time: string;
curl: string;
error?: string;

View file

@ -7,6 +7,10 @@ export default defineConfig(({ command }) => ({
server: {
port: 5173,
proxy: {
"/v2": {
target: "http://localhost:2468",
changeOrigin: true,
},
"/v1": {
target: "http://localhost:2468",
changeOrigin: true,

View file

@ -18,7 +18,7 @@ const faqs = [
{
question: 'How is session data persisted?',
answer:
"This SDK does not handle persisting session data. Events stream in a universal JSON schema that you can persist anywhere. Consider using Postgres or <a href='https://rivet.gg' target='_blank' rel='noopener noreferrer' class='text-orange-400 hover:underline'>Rivet Actors</a> for data persistence.",
"This SDK does not handle persisting session data. In v2, traffic is ACP JSON-RPC over <code>/v2/rpc</code>; persist envelopes in your own storage if you need replay or auditing.",
},
{
question: 'Can I run this locally or does it require a sandbox provider?',