diff --git a/docs/openapi.json b/docs/openapi.json
index 7bd9301..900b945 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "sandbox-agent",
- "description": "",
+ "description": "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.",
"contact": {
"name": "Rivet Gaming, LLC",
"email": "developer@rivet.gg"
diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html
index 543649d..a7bb27e 100644
--- a/frontend/packages/inspector/index.html
+++ b/frontend/packages/inspector/index.html
@@ -460,7 +460,7 @@
position: absolute;
top: 30px;
right: 0;
- min-width: 140px;
+ min-width: 200px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 8px;
@@ -484,7 +484,6 @@
transition: all var(--transition);
display: flex;
align-items: center;
- justify-content: space-between;
gap: 8px;
}
@@ -494,25 +493,14 @@
}
.sidebar-add-option:hover .agent-badge {
- background: rgba(255, 255, 255, 0.2);
- color: #fff;
+ color: rgba(255, 255, 255, 0.6);
}
.agent-option-name {
- flex: 1;
- min-width: 0;
- }
-
- .agent-option-badges {
- display: flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
+ white-space: nowrap;
}
.agent-badge {
- padding: 2px 6px;
- border-radius: 4px;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
@@ -520,12 +508,10 @@
}
.agent-badge.installed {
- background: rgba(48, 209, 88, 0.15);
- color: var(--success);
+ color: var(--muted);
}
.agent-badge.version {
- background: var(--border-2);
color: var(--muted);
}
@@ -734,7 +720,7 @@
left: 50%;
transform: translateX(-50%);
margin-top: 4px;
- min-width: 160px;
+ min-width: 200px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 8px;
diff --git a/frontend/packages/inspector/src/components/SessionSidebar.tsx b/frontend/packages/inspector/src/components/SessionSidebar.tsx
index 525305f..42f2640 100644
--- a/frontend/packages/inspector/src/components/SessionSidebar.tsx
+++ b/frontend/packages/inspector/src/components/SessionSidebar.tsx
@@ -82,12 +82,8 @@ const SessionSidebar = ({
}}
>
{agentLabels[agent.id] ?? agent.id}
- {agent.installed && (
-
- Installed
- {agent.version && v{agent.version}}
-
- )}
+ {agent.installed && Installed}
+ {agent.version && v{agent.version}}
))}
diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx
index 9733465..2586f63 100644
--- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx
+++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx
@@ -202,12 +202,8 @@ const ChatPanel = ({
}}
>
{agentLabels[agent.id] ?? agent.id}
- {agent.installed && (
-
- Installed
- {agent.version && v{agent.version}}
-
- )}
+ {agent.installed && Installed}
+ {agent.version && v{agent.version}}
))}
diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs
index 12e4ced..6734a31 100644
--- a/server/packages/sandbox-agent/src/router.rs
+++ b/server/packages/sandbox-agent/src/router.rs
@@ -265,6 +265,7 @@ struct SessionState {
opencode_stream_started: bool,
codex_sender: Option>,
session_started_emitted: bool,
+ last_claude_message_id: Option,
}
#[derive(Debug, Clone)]
@@ -318,6 +319,7 @@ impl SessionState {
opencode_stream_started: false,
codex_sender: None,
session_started_emitted: false,
+ last_claude_message_id: None,
})
}
@@ -2066,6 +2068,11 @@ impl SessionManager {
break;
}
}
+ } else if agent == AgentId::Claude {
+ let conversions = self.parse_claude_line(&line, &session_id).await;
+ if !conversions.is_empty() {
+ let _ = self.record_conversions(&session_id, conversions).await;
+ }
} else {
let conversions = parse_agent_line(agent, &line, &session_id);
if !conversions.is_empty() {
@@ -2176,6 +2183,53 @@ impl SessionManager {
Ok(session.record_conversions(conversions))
}
+ async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return Vec::new();
+ }
+ let mut value: Value = match serde_json::from_str(trimmed) {
+ Ok(value) => value,
+ Err(err) => {
+ return vec![agent_unparsed(
+ "claude",
+ &err.to_string(),
+ Value::String(trimmed.to_string()),
+ )];
+ }
+ };
+ let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
+ if event_type == "assistant" {
+ if let Some(id) = value
+ .get("message")
+ .and_then(|message| message.get("id"))
+ .and_then(Value::as_str)
+ {
+ let mut sessions = self.sessions.lock().await;
+ if let Some(session) = Self::session_mut(&mut sessions, session_id) {
+ session.last_claude_message_id = Some(id.to_string());
+ }
+ }
+ } else if event_type == "result"
+ && value.get("message_id").is_none()
+ && value.get("messageId").is_none()
+ {
+ let last_id = {
+ let sessions = self.sessions.lock().await;
+ Self::session_ref(&sessions, session_id)
+ .and_then(|session| session.last_claude_message_id.clone())
+ };
+ if let Some(id) = last_id {
+ if let Some(map) = value.as_object_mut() {
+ map.insert("message_id".to_string(), Value::String(id));
+ }
+ }
+ }
+
+ convert_claude::event_to_universal_with_session(&value, session_id.to_string())
+ .unwrap_or_else(|err| vec![agent_unparsed("claude", &err, value)])
+ }
+
async fn record_error(
&self,
session_id: &str,
diff --git a/server/packages/universal-agent-schema/src/agents/claude.rs b/server/packages/universal-agent-schema/src/agents/claude.rs
index 8d16c75..0d085b4 100644
--- a/server/packages/universal-agent-schema/src/agents/claude.rs
+++ b/server/packages/universal-agent-schema/src/agents/claude.rs
@@ -31,6 +31,7 @@ pub fn event_to_universal_with_session(
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
let mut conversions = match event_type {
"system" => vec![system_event_to_universal(event)],
+ "user" => Vec::new(),
"assistant" => assistant_event_to_universal(event, &session_id),
"tool_use" => tool_use_event_to_universal(event, &session_id),
"tool_result" => tool_result_event_to_universal(event),
@@ -63,7 +64,7 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec Vec {
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec {
// The `result` event completes the message started by `assistant`.
// Use the same native_item_id so they link to the same universal item.
- let native_message_id = format!("{session_id}_message");
+ let native_message_id = claude_message_id(event, session_id);
let result_text = event
.get("result")
.and_then(Value::as_str)
@@ -251,6 +252,17 @@ fn result_event_to_universal(event: &Value, session_id: &str) -> Vec String {
+ event
+ .get("message")
+ .and_then(|message| message.get("id"))
+ .and_then(Value::as_str)
+ .or_else(|| event.get("message_id").and_then(Value::as_str))
+ .or_else(|| event.get("messageId").and_then(Value::as_str))
+ .map(|id| id.to_string())
+ .unwrap_or_else(|| format!("{session_id}_message"))
+}
+
fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec {
let mut events = Vec::new();
if synthetic_start {