mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
feat: add Claude adapter improvements for HITL support (#30)
* feat: add Claude adapter improvements for HITL support - Add question and permission handling for Claude sessions - Add Claude sender channel for interactive communication - Add stream event and control request handling - Update agent compatibility documentation * fix: restore Claude HITL streaming input and permission handling - Add streaming_input field to SpawnOptions for Claude stdin streaming - Enable --input-format stream-json, --permission-prompt-tool stdio flags - Pipe stdin for Claude (not just Codex) in spawn_streaming - Update Claude capabilities: permissions, questions, tool_calls, tool_results, streaming_deltas - Fix permission mode normalization to respect user's choice instead of forcing bypass - Add acceptEdits permission mode support - Add libc dependency for is_running_as_root check
This commit is contained in:
parent
c7d6482fd4
commit
0ee60920c8
7 changed files with 513 additions and 67 deletions
|
|
@ -8,14 +8,14 @@ The universal API normalizes different coding agents into a consistent interface
|
||||||
|
|
||||||
## Feature Matrix
|
## Feature Matrix
|
||||||
|
|
||||||
| Feature | [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)* | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) |
|
| Feature | [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) |
|
||||||
|---------|:-----------:|:-----:|:--------:|:---:|
|
|---------|:-----------:|:-----:|:--------:|:---:|
|
||||||
| Stability | Stable | Stable | Experimental | Experimental |
|
| Stability | Stable | Stable | Experimental | Experimental |
|
||||||
| Text Messages | ✓ | ✓ | ✓ | ✓ |
|
| Text Messages | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Tool Calls | —* | ✓ | ✓ | ✓ |
|
| Tool Calls | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Tool Results | —* | ✓ | ✓ | ✓ |
|
| Tool Results | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Questions (HITL) | —* | | ✓ | |
|
| Questions (HITL) | ✓ | | ✓ | |
|
||||||
| Permissions (HITL) | —* | | ✓ | |
|
| Permissions (HITL) | ✓ | | ✓ | |
|
||||||
| Images | | ✓ | ✓ | |
|
| Images | | ✓ | ✓ | |
|
||||||
| File Attachments | | ✓ | ✓ | |
|
| File Attachments | | ✓ | ✓ | |
|
||||||
| Session Lifecycle | | ✓ | ✓ | |
|
| Session Lifecycle | | ✓ | ✓ | |
|
||||||
|
|
@ -24,9 +24,7 @@ The universal API normalizes different coding agents into a consistent interface
|
||||||
| Command Execution | | ✓ | | |
|
| Command Execution | | ✓ | | |
|
||||||
| File Changes | | ✓ | | |
|
| File Changes | | ✓ | | |
|
||||||
| MCP Tools | | ✓ | | |
|
| MCP Tools | | ✓ | | |
|
||||||
| Streaming Deltas | | ✓ | ✓ | |
|
| Streaming Deltas | ✓ | ✓ | ✓ | |
|
||||||
|
|
||||||
\* Coming imminently
|
|
||||||
|
|
||||||
## Feature Descriptions
|
## Feature Descriptions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ Events / Message Flow
|
||||||
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
|
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
|
||||||
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
|
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
|
||||||
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
|
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
|
||||||
| message.delta | synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
|
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
|
||||||
| tool call | synthetic from tool usage | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
||||||
| tool result | synthetic from tool usage | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
||||||
| permission.requested | none | none | type=permission.asked | none |
|
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none |
|
||||||
| permission.resolved | none | none | type=permission.replied | none |
|
| permission.resolved | daemon reply to can_use_tool | none | type=permission.replied | none |
|
||||||
| question.requested | ExitPlanMode tool (synthetic)| experimental request_user_input (payload) | type=question.asked | none |
|
| question.requested | tool_use (AskUserQuestion) | experimental request_user_input (payload) | type=question.asked | none |
|
||||||
| question.resolved | ExitPlanMode reply (synthetic)| experimental request_user_input (payload) | type=question.replied / question.rejected | none |
|
| question.resolved | tool_result (AskUserQuestion) | experimental request_user_input (payload) | type=question.replied / question.rejected | none |
|
||||||
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
|
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
|
||||||
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
||||||
|
|
||||||
|
|
@ -50,10 +50,11 @@ Synthetics
|
||||||
| session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
|
| session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
|
||||||
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
|
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
|
||||||
| user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
|
| user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
|
||||||
| question events (Claude) | Plan mode ExitPlanMode tool usage | question.requested/resolved | Synthetic mapping from tool call/result |
|
| question events (Claude) | AskUserQuestion tool usage | question.requested/resolved | Derived from tool_use blocks (source=agent) |
|
||||||
| native_session_id (Codex) | Codex uses threadId | native_session_id | Intentionally merged threadId into native_session_id |
|
| native_session_id (Codex) | Codex uses threadId | native_session_id | Intentionally merged threadId into native_session_id |
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
| message.delta (Claude/Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
| message.delta (Claude) | No native deltas emitted | item.delta | Synthetic delta with full message content; source=daemon |
|
||||||
|
| message.delta (Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta |
|
| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta |
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
|
|
@ -62,11 +63,12 @@ Delta handling
|
||||||
|
|
||||||
- Codex emits agent message and other deltas (e.g., item/agentMessage/delta).
|
- Codex emits agent message and other deltas (e.g., item/agentMessage/delta).
|
||||||
- OpenCode emits part deltas via message.part.updated with a delta string.
|
- OpenCode emits part deltas via message.part.updated with a delta string.
|
||||||
- Claude and Amp do not emit deltas in their schemas.
|
- Claude can emit stream_event deltas when partial streaming is enabled; Amp does not emit deltas.
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
- Always emit item.delta across all providers.
|
- Always emit item.delta across all providers.
|
||||||
- For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed.
|
- For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed.
|
||||||
|
- For Claude when partial streaming is enabled, forward native deltas and skip the synthetic full-content delta.
|
||||||
- For providers with native deltas, forward as-is; also emit item.completed when final content is known.
|
- For providers with native deltas, forward as-is; also emit item.completed when final content is known.
|
||||||
|
|
||||||
Message normalization notes
|
Message normalization notes
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,36 @@ Claude conflates agent mode and permission mode - `plan` is a permission restric
|
||||||
| `plan` | `--permission-mode plan` | Read-only, must ExitPlanMode to execute |
|
| `plan` | `--permission-mode plan` | Read-only, must ExitPlanMode to execute |
|
||||||
| `bypassPermissions` | `--dangerously-skip-permissions` | Skip all permission checks |
|
| `bypassPermissions` | `--dangerously-skip-permissions` | Skip all permission checks |
|
||||||
|
|
||||||
|
### Root Restrictions
|
||||||
|
|
||||||
|
**Claude refuses to run with `--dangerously-skip-permissions` when running as root (uid 0).**
|
||||||
|
|
||||||
|
This is a security measure built into Claude Code. When running as root:
|
||||||
|
- The CLI outputs: `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`
|
||||||
|
- The process exits immediately without executing
|
||||||
|
|
||||||
|
This affects container environments (Docker, Daytona, E2B, etc.) which commonly run as root.
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
1. Run as a non-root user in the container
|
||||||
|
2. Use `default` permission mode (but this requires interactive approval)
|
||||||
|
3. Use `acceptEdits` mode for file operations (still requires Bash approval)
|
||||||
|
|
||||||
|
### Headless Permission Behavior
|
||||||
|
|
||||||
|
When permissions are denied in headless mode (`--print --output-format stream-json`):
|
||||||
|
|
||||||
|
1. Claude emits a `tool_use` event for the tool (e.g., Write, Bash)
|
||||||
|
2. A `user` event follows with `tool_result` containing `is_error: true`
|
||||||
|
3. Error message: `"Claude requested permissions to X, but you haven't granted it yet."`
|
||||||
|
4. Final `result` event includes `permission_denials` array listing all denied tools
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{...}}]}}
|
||||||
|
{"type":"user","message":{"content":[{"type":"tool_result","is_error":true,"content":"Claude requested permissions to write to /tmp/test.txt, but you haven't granted it yet."}]}}
|
||||||
|
{"type":"result","permission_denials":[{"tool_name":"Write","tool_use_id":"...","tool_input":{...}}]}
|
||||||
|
```
|
||||||
|
|
||||||
### Subagent Types
|
### Subagent Types
|
||||||
|
|
||||||
Claude supports spawning subagents via the `Task` tool with `subagent_type`:
|
Claude supports spawning subagents via the `Task` tool with `subagent_type`:
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,6 @@ impl AgentManager {
|
||||||
match agent {
|
match agent {
|
||||||
AgentId::Claude => {
|
AgentId::Claude => {
|
||||||
command
|
command
|
||||||
.arg("--print")
|
|
||||||
.arg("--output-format")
|
.arg("--output-format")
|
||||||
.arg("stream-json")
|
.arg("stream-json")
|
||||||
.arg("--verbose");
|
.arg("--verbose");
|
||||||
|
|
@ -234,9 +233,21 @@ impl AgentManager {
|
||||||
Some("bypass") => {
|
Some("bypass") => {
|
||||||
command.arg("--dangerously-skip-permissions");
|
command.arg("--dangerously-skip-permissions");
|
||||||
}
|
}
|
||||||
|
Some("acceptEdits") => {
|
||||||
|
command.arg("--permission-mode").arg("acceptEdits");
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
command.arg(&options.prompt);
|
if options.streaming_input {
|
||||||
|
command
|
||||||
|
.arg("--input-format")
|
||||||
|
.arg("stream-json")
|
||||||
|
.arg("--permission-prompt-tool")
|
||||||
|
.arg("stdio")
|
||||||
|
.arg("--include-partial-messages");
|
||||||
|
} else {
|
||||||
|
command.arg("--print").arg("--").arg(&options.prompt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AgentId::Codex => {
|
AgentId::Codex => {
|
||||||
if options.session_id.is_some() {
|
if options.session_id.is_some() {
|
||||||
|
|
@ -305,15 +316,18 @@ impl AgentManager {
|
||||||
pub fn spawn_streaming(
|
pub fn spawn_streaming(
|
||||||
&self,
|
&self,
|
||||||
agent: AgentId,
|
agent: AgentId,
|
||||||
options: SpawnOptions,
|
mut options: SpawnOptions,
|
||||||
) -> Result<StreamingSpawn, AgentError> {
|
) -> Result<StreamingSpawn, AgentError> {
|
||||||
let codex_options = if agent == AgentId::Codex {
|
let codex_options = if agent == AgentId::Codex {
|
||||||
Some(options.clone())
|
Some(options.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
if agent == AgentId::Claude {
|
||||||
|
options.streaming_input = true;
|
||||||
|
}
|
||||||
let mut command = self.build_command(agent, &options)?;
|
let mut command = self.build_command(agent, &options)?;
|
||||||
if agent == AgentId::Codex {
|
if matches!(agent, AgentId::Codex | AgentId::Claude) {
|
||||||
command.stdin(Stdio::piped());
|
command.stdin(Stdio::piped());
|
||||||
}
|
}
|
||||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
@ -539,7 +553,6 @@ impl AgentManager {
|
||||||
match agent {
|
match agent {
|
||||||
AgentId::Claude => {
|
AgentId::Claude => {
|
||||||
command
|
command
|
||||||
.arg("--print")
|
|
||||||
.arg("--output-format")
|
.arg("--output-format")
|
||||||
.arg("stream-json")
|
.arg("stream-json")
|
||||||
.arg("--verbose");
|
.arg("--verbose");
|
||||||
|
|
@ -556,9 +569,21 @@ impl AgentManager {
|
||||||
Some("bypass") => {
|
Some("bypass") => {
|
||||||
command.arg("--dangerously-skip-permissions");
|
command.arg("--dangerously-skip-permissions");
|
||||||
}
|
}
|
||||||
|
Some("acceptEdits") => {
|
||||||
|
command.arg("--permission-mode").arg("acceptEdits");
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
command.arg(&options.prompt);
|
if options.streaming_input {
|
||||||
|
command
|
||||||
|
.arg("--input-format")
|
||||||
|
.arg("stream-json")
|
||||||
|
.arg("--permission-prompt-tool")
|
||||||
|
.arg("stdio")
|
||||||
|
.arg("--include-partial-messages");
|
||||||
|
} else {
|
||||||
|
command.arg(&options.prompt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AgentId::Codex => {
|
AgentId::Codex => {
|
||||||
if options.session_id.is_some() {
|
if options.session_id.is_some() {
|
||||||
|
|
@ -646,6 +671,8 @@ pub struct SpawnOptions {
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
pub working_dir: Option<PathBuf>,
|
pub working_dir: Option<PathBuf>,
|
||||||
pub env: HashMap<String, String>,
|
pub env: HashMap<String, String>,
|
||||||
|
/// Use stream-json input via stdin (Claude only).
|
||||||
|
pub streaming_input: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnOptions {
|
impl SpawnOptions {
|
||||||
|
|
@ -659,6 +686,7 @@ impl SpawnOptions {
|
||||||
session_id: None,
|
session_id: None,
|
||||||
working_dir: None,
|
working_dir: None,
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
streaming_input: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ tracing-subscriber.workspace = true
|
||||||
include_dir.workspace = true
|
include_dir.workspace = true
|
||||||
tempfile = { workspace = true, optional = true }
|
tempfile = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body-util.workspace = true
|
http-body-util.workspace = true
|
||||||
insta.workspace = true
|
insta.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,7 @@ struct SessionState {
|
||||||
broadcaster: broadcast::Sender<UniversalEvent>,
|
broadcaster: broadcast::Sender<UniversalEvent>,
|
||||||
opencode_stream_started: bool,
|
opencode_stream_started: bool,
|
||||||
codex_sender: Option<mpsc::UnboundedSender<String>>,
|
codex_sender: Option<mpsc::UnboundedSender<String>>,
|
||||||
|
claude_sender: Option<mpsc::UnboundedSender<String>>,
|
||||||
session_started_emitted: bool,
|
session_started_emitted: bool,
|
||||||
last_claude_message_id: Option<String>,
|
last_claude_message_id: Option<String>,
|
||||||
claude_message_counter: u64,
|
claude_message_counter: u64,
|
||||||
|
|
@ -322,6 +323,7 @@ impl SessionState {
|
||||||
broadcaster,
|
broadcaster,
|
||||||
opencode_stream_started: false,
|
opencode_stream_started: false,
|
||||||
codex_sender: None,
|
codex_sender: None,
|
||||||
|
claude_sender: None,
|
||||||
session_started_emitted: false,
|
session_started_emitted: false,
|
||||||
last_claude_message_id: None,
|
last_claude_message_id: None,
|
||||||
claude_message_counter: 0,
|
claude_message_counter: 0,
|
||||||
|
|
@ -382,6 +384,15 @@ impl SessionState {
|
||||||
self.codex_sender.clone()
|
self.codex_sender.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_claude_sender(&mut self, sender: Option<mpsc::UnboundedSender<String>>) {
|
||||||
|
self.claude_sender = sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn claude_sender(&self) -> Option<mpsc::UnboundedSender<String>> {
|
||||||
|
self.claude_sender.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_conversion(&mut self, mut conversion: EventConversion) -> Vec<EventConversion> {
|
fn normalize_conversion(&mut self, mut conversion: EventConversion) -> Vec<EventConversion> {
|
||||||
if self.native_session_id.is_none() && conversion.native_session_id.is_some() {
|
if self.native_session_id.is_none() && conversion.native_session_id.is_some() {
|
||||||
self.native_session_id = conversion.native_session_id.clone();
|
self.native_session_id = conversion.native_session_id.clone();
|
||||||
|
|
@ -1621,6 +1632,11 @@ impl SessionManager {
|
||||||
|
|
||||||
let manager = self.agent_manager.clone();
|
let manager = self.agent_manager.clone();
|
||||||
let prompt = message;
|
let prompt = message;
|
||||||
|
let initial_input = if session_snapshot.agent == AgentId::Claude {
|
||||||
|
Some(claude_user_message_line(&session_snapshot, &prompt))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let credentials = tokio::task::spawn_blocking(move || {
|
let credentials = tokio::task::spawn_blocking(move || {
|
||||||
let options = CredentialExtractionOptions::new();
|
let options = CredentialExtractionOptions::new();
|
||||||
extract_all_credentials(&options)
|
extract_all_credentials(&options)
|
||||||
|
|
@ -1630,7 +1646,7 @@ impl SessionManager {
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let spawn_options = build_spawn_options(&session_snapshot, prompt, credentials);
|
let spawn_options = build_spawn_options(&session_snapshot, prompt.clone(), credentials);
|
||||||
let agent_id = session_snapshot.agent;
|
let agent_id = session_snapshot.agent;
|
||||||
let spawn_result =
|
let spawn_result =
|
||||||
tokio::task::spawn_blocking(move || manager.spawn_streaming(agent_id, spawn_options))
|
tokio::task::spawn_blocking(move || manager.spawn_streaming(agent_id, spawn_options))
|
||||||
|
|
@ -1649,7 +1665,7 @@ impl SessionManager {
|
||||||
let manager = Arc::clone(self);
|
let manager = Arc::clone(self);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
manager
|
manager
|
||||||
.consume_spawn(session_id, agent_id, spawn_result)
|
.consume_spawn(session_id, agent_id, spawn_result, initial_input)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1847,7 +1863,7 @@ impl SessionManager {
|
||||||
question_id: &str,
|
question_id: &str,
|
||||||
answers: Vec<Vec<String>>,
|
answers: Vec<Vec<String>>,
|
||||||
) -> Result<(), SandboxError> {
|
) -> Result<(), SandboxError> {
|
||||||
let (agent, native_session_id, pending_question) = {
|
let (agent, native_session_id, pending_question, claude_sender) = {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||||
SandboxError::SessionNotFound {
|
SandboxError::SessionNotFound {
|
||||||
|
|
@ -1863,7 +1879,12 @@ impl SessionManager {
|
||||||
if let Some(err) = session.ended_error() {
|
if let Some(err) = session.ended_error() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
(session.agent, session.native_session_id.clone(), pending)
|
(
|
||||||
|
session.agent,
|
||||||
|
session.native_session_id.clone(),
|
||||||
|
pending,
|
||||||
|
session.claude_sender(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = answers.first().and_then(|inner| inner.first()).cloned();
|
let response = answers.first().and_then(|inner| inner.first()).cloned();
|
||||||
|
|
@ -1877,6 +1898,16 @@ impl SessionManager {
|
||||||
})?;
|
})?;
|
||||||
self.opencode_question_reply(&agent_session_id, question_id, answers)
|
self.opencode_question_reply(&agent_session_id, question_id, answers)
|
||||||
.await?;
|
.await?;
|
||||||
|
} else if agent == AgentId::Claude {
|
||||||
|
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
|
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||||
|
let response_text = response.clone().unwrap_or_default();
|
||||||
|
let line = claude_tool_result_line(&session_id, question_id, &response_text, false);
|
||||||
|
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
// TODO: Forward question replies to subprocess agents.
|
// TODO: Forward question replies to subprocess agents.
|
||||||
}
|
}
|
||||||
|
|
@ -1905,7 +1936,7 @@ impl SessionManager {
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
question_id: &str,
|
question_id: &str,
|
||||||
) -> Result<(), SandboxError> {
|
) -> Result<(), SandboxError> {
|
||||||
let (agent, native_session_id, pending_question) = {
|
let (agent, native_session_id, pending_question, claude_sender) = {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||||
SandboxError::SessionNotFound {
|
SandboxError::SessionNotFound {
|
||||||
|
|
@ -1921,7 +1952,12 @@ impl SessionManager {
|
||||||
if let Some(err) = session.ended_error() {
|
if let Some(err) = session.ended_error() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
(session.agent, session.native_session_id.clone(), pending)
|
(
|
||||||
|
session.agent,
|
||||||
|
session.native_session_id.clone(),
|
||||||
|
pending,
|
||||||
|
session.claude_sender(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if agent == AgentId::Opencode {
|
if agent == AgentId::Opencode {
|
||||||
|
|
@ -1933,6 +1969,20 @@ impl SessionManager {
|
||||||
})?;
|
})?;
|
||||||
self.opencode_question_reject(&agent_session_id, question_id)
|
self.opencode_question_reject(&agent_session_id, question_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
} else if agent == AgentId::Claude {
|
||||||
|
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
|
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||||
|
let line = claude_tool_result_line(
|
||||||
|
&session_id,
|
||||||
|
question_id,
|
||||||
|
"User rejected the question.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
// TODO: Forward question rejections to subprocess agents.
|
// TODO: Forward question rejections to subprocess agents.
|
||||||
}
|
}
|
||||||
|
|
@ -1963,7 +2013,7 @@ impl SessionManager {
|
||||||
reply: PermissionReply,
|
reply: PermissionReply,
|
||||||
) -> Result<(), SandboxError> {
|
) -> Result<(), SandboxError> {
|
||||||
let reply_for_status = reply.clone();
|
let reply_for_status = reply.clone();
|
||||||
let (agent, native_session_id, pending_permission) = {
|
let (agent, native_session_id, pending_permission, claude_sender) = {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||||
SandboxError::SessionNotFound {
|
SandboxError::SessionNotFound {
|
||||||
|
|
@ -1983,6 +2033,7 @@ impl SessionManager {
|
||||||
session.agent,
|
session.agent,
|
||||||
session.native_session_id.clone(),
|
session.native_session_id.clone(),
|
||||||
pending,
|
pending,
|
||||||
|
session.claude_sender(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2050,6 +2101,44 @@ impl SessionManager {
|
||||||
})?;
|
})?;
|
||||||
self.opencode_permission_reply(&agent_session_id, permission_id, reply.clone())
|
self.opencode_permission_reply(&agent_session_id, permission_id, reply.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
} else if agent == AgentId::Claude {
|
||||||
|
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
|
let metadata = pending_permission
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|pending| pending.metadata.as_ref())
|
||||||
|
.and_then(Value::as_object);
|
||||||
|
let updated_input = metadata
|
||||||
|
.and_then(|map| map.get("input"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let mut response_map = serde_json::Map::new();
|
||||||
|
match reply {
|
||||||
|
PermissionReply::Reject => {
|
||||||
|
response_map.insert(
|
||||||
|
"message".to_string(),
|
||||||
|
Value::String("Permission denied.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PermissionReply::Once | PermissionReply::Always => {
|
||||||
|
if !updated_input.is_null() {
|
||||||
|
response_map.insert("updatedInput".to_string(), updated_input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response_value = Value::Object(response_map);
|
||||||
|
|
||||||
|
let behavior = match reply {
|
||||||
|
PermissionReply::Reject => "deny",
|
||||||
|
PermissionReply::Once | PermissionReply::Always => "allow",
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = claude_control_response_line(permission_id, behavior, response_value);
|
||||||
|
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
message: "Claude session is not active".to_string(),
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
// TODO: Forward permission replies to subprocess agents.
|
// TODO: Forward permission replies to subprocess agents.
|
||||||
}
|
}
|
||||||
|
|
@ -2151,6 +2240,7 @@ impl SessionManager {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
agent: AgentId,
|
agent: AgentId,
|
||||||
spawn: StreamingSpawn,
|
spawn: StreamingSpawn,
|
||||||
|
initial_input: Option<String>,
|
||||||
) {
|
) {
|
||||||
let StreamingSpawn {
|
let StreamingSpawn {
|
||||||
mut child,
|
mut child,
|
||||||
|
|
@ -2197,6 +2287,22 @@ impl SessionManager {
|
||||||
if let (Some(state), Some(sender)) = (codex_state.as_mut(), codex_sender.as_ref()) {
|
if let (Some(state), Some(sender)) = (codex_state.as_mut(), codex_sender.as_ref()) {
|
||||||
state.start(sender);
|
state.start(sender);
|
||||||
}
|
}
|
||||||
|
} else if agent == AgentId::Claude {
|
||||||
|
if let Some(stdin) = stdin {
|
||||||
|
let (writer_tx, writer_rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
{
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||||
|
session.set_claude_sender(Some(writer_tx.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(initial) = initial_input {
|
||||||
|
let _ = writer_tx.send(initial);
|
||||||
|
}
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
write_lines(stdin, writer_rx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(line) = rx.recv().await {
|
while let Some(line) = rx.recv().await {
|
||||||
|
|
@ -2214,6 +2320,14 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if agent == AgentId::Claude {
|
} else if agent == AgentId::Claude {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(&line) {
|
||||||
|
if value.get("type").and_then(Value::as_str) == Some("result") {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||||
|
session.set_claude_sender(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let conversions = self.parse_claude_line(&line, &session_id).await;
|
let conversions = self.parse_claude_line(&line, &session_id).await;
|
||||||
if !conversions.is_empty() {
|
if !conversions.is_empty() {
|
||||||
let _ = self.record_conversions(&session_id, conversions).await;
|
let _ = self.record_conversions(&session_id, conversions).await;
|
||||||
|
|
@ -2231,6 +2345,11 @@ impl SessionManager {
|
||||||
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||||
session.set_codex_sender(None);
|
session.set_codex_sender(None);
|
||||||
}
|
}
|
||||||
|
} else if agent == AgentId::Claude {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||||
|
session.set_claude_sender(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if terminate_early {
|
if terminate_early {
|
||||||
|
|
@ -3779,14 +3898,14 @@ fn agent_supports_item_started(agent: AgentId) -> bool {
|
||||||
|
|
||||||
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
match agent {
|
match agent {
|
||||||
// Headless Claude CLI does not expose AskUserQuestion and does not emit tool_result,
|
// Claude CLI supports tool calls/results and permission prompts via the SDK control protocol,
|
||||||
// so we keep these capabilities off until we switch to an SDK-backed wrapper.
|
// but we still emit synthetic item.started events.
|
||||||
AgentId::Claude => AgentCapabilities {
|
AgentId::Claude => AgentCapabilities {
|
||||||
plan_mode: false,
|
plan_mode: false,
|
||||||
permissions: false,
|
permissions: true,
|
||||||
questions: false,
|
questions: true,
|
||||||
tool_calls: false,
|
tool_calls: true,
|
||||||
tool_results: false,
|
tool_results: true,
|
||||||
text_messages: true,
|
text_messages: true,
|
||||||
images: false,
|
images: false,
|
||||||
file_attachments: false,
|
file_attachments: false,
|
||||||
|
|
@ -3797,7 +3916,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
command_execution: false,
|
command_execution: false,
|
||||||
file_changes: false,
|
file_changes: false,
|
||||||
mcp_tools: false,
|
mcp_tools: false,
|
||||||
streaming_deltas: false,
|
streaming_deltas: true,
|
||||||
item_started: false,
|
item_started: false,
|
||||||
shared_process: false, // per-turn subprocess with --resume
|
shared_process: false, // per-turn subprocess with --resume
|
||||||
},
|
},
|
||||||
|
|
@ -3990,12 +4109,24 @@ fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<Stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the current process is running as root (uid 0)
|
||||||
|
fn is_running_as_root() -> bool {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
unsafe { libc::getuid() == 0 }
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_permission_mode(
|
fn normalize_permission_mode(
|
||||||
agent: AgentId,
|
agent: AgentId,
|
||||||
permission_mode: Option<&str>,
|
permission_mode: Option<&str>,
|
||||||
) -> Result<String, SandboxError> {
|
) -> Result<String, SandboxError> {
|
||||||
let mode = match permission_mode.unwrap_or("default") {
|
let mode = match permission_mode.unwrap_or("default") {
|
||||||
"default" | "plan" | "bypass" => permission_mode.unwrap_or("default"),
|
"default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"),
|
||||||
value => {
|
value => {
|
||||||
return Err(SandboxError::InvalidRequest {
|
return Err(SandboxError::InvalidRequest {
|
||||||
message: format!("invalid permission mode: {value}"),
|
message: format!("invalid permission mode: {value}"),
|
||||||
|
|
@ -4004,14 +4135,20 @@ fn normalize_permission_mode(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if agent == AgentId::Claude {
|
if agent == AgentId::Claude {
|
||||||
if mode == "plan" {
|
// Claude refuses --dangerously-skip-permissions when running as root,
|
||||||
return Err(SandboxError::ModeNotSupported {
|
// which is common in container environments (Docker, Daytona, E2B).
|
||||||
agent: agent.as_str().to_string(),
|
// Return an error if user explicitly requests bypass while running as root.
|
||||||
mode: mode.to_string(),
|
if mode == "bypass" && is_running_as_root() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "permission mode 'bypass' is not supported when running as root (Claude refuses --dangerously-skip-permissions with root privileges)".to_string(),
|
||||||
}
|
}
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
return Ok("bypass".to_string());
|
// Pass through bypass/acceptEdits/plan if explicitly requested, otherwise use default
|
||||||
|
if mode == "bypass" || mode == "acceptEdits" || mode == "plan" {
|
||||||
|
return Ok(mode.to_string());
|
||||||
|
}
|
||||||
|
return Ok("default".to_string());
|
||||||
}
|
}
|
||||||
let supported = match agent {
|
let supported = match agent {
|
||||||
AgentId::Claude => false,
|
AgentId::Claude => false,
|
||||||
|
|
@ -4117,6 +4254,87 @@ fn build_spawn_options(
|
||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_input_session_id(session: &SessionSnapshot) -> String {
|
||||||
|
session
|
||||||
|
.native_session_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| session.session_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_user_message_line(session: &SessionSnapshot, message: &str) -> String {
|
||||||
|
let session_id = claude_input_session_id(session);
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": message,
|
||||||
|
},
|
||||||
|
"parent_tool_use_id": null,
|
||||||
|
"session_id": session_id,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_tool_result_line(
|
||||||
|
session_id: &str,
|
||||||
|
tool_use_id: &str,
|
||||||
|
content: &str,
|
||||||
|
is_error: bool,
|
||||||
|
) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tool_use_id,
|
||||||
|
"content": content,
|
||||||
|
"is_error": is_error,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"parent_tool_use_id": null,
|
||||||
|
"session_id": session_id,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_control_response_line(
|
||||||
|
request_id: &str,
|
||||||
|
behavior: &str,
|
||||||
|
response: Value,
|
||||||
|
) -> String {
|
||||||
|
let mut response_obj = serde_json::Map::new();
|
||||||
|
response_obj.insert(
|
||||||
|
"behavior".to_string(),
|
||||||
|
Value::String(behavior.to_string()),
|
||||||
|
);
|
||||||
|
if let Some(message) = response.get("message") {
|
||||||
|
response_obj.insert("message".to_string(), message.clone());
|
||||||
|
}
|
||||||
|
if let Some(updated_input) = response.get("updatedInput") {
|
||||||
|
response_obj.insert("updatedInput".to_string(), updated_input.clone());
|
||||||
|
}
|
||||||
|
if let Some(updated_permissions) = response.get("updatedPermissions") {
|
||||||
|
response_obj.insert(
|
||||||
|
"updatedPermissions".to_string(),
|
||||||
|
updated_permissions.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(interrupt) = response.get("interrupt") {
|
||||||
|
response_obj.insert("interrupt".to_string(), interrupt.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "control_response",
|
||||||
|
"response": {
|
||||||
|
"subtype": "success",
|
||||||
|
"request_id": request_id,
|
||||||
|
"response": Value::Object(response_obj),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
|
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
|
||||||
let mut reader = BufReader::new(reader);
|
let mut reader = BufReader::new(reader);
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ use crate::{
|
||||||
ContentPart,
|
ContentPart,
|
||||||
EventConversion,
|
EventConversion,
|
||||||
ItemEventData,
|
ItemEventData,
|
||||||
|
ItemDeltaData,
|
||||||
ItemKind,
|
ItemKind,
|
||||||
ItemRole,
|
ItemRole,
|
||||||
ItemStatus,
|
ItemStatus,
|
||||||
|
PermissionEventData,
|
||||||
|
PermissionStatus,
|
||||||
QuestionEventData,
|
QuestionEventData,
|
||||||
QuestionStatus,
|
QuestionStatus,
|
||||||
SessionStartedData,
|
SessionStartedData,
|
||||||
|
|
@ -31,11 +34,14 @@ pub fn event_to_universal_with_session(
|
||||||
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
|
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
let mut conversions = match event_type {
|
let mut conversions = match event_type {
|
||||||
"system" => vec![system_event_to_universal(event)],
|
"system" => vec![system_event_to_universal(event)],
|
||||||
"user" => Vec::new(),
|
"user" => user_event_to_universal(event),
|
||||||
"assistant" => assistant_event_to_universal(event, &session_id),
|
"assistant" => assistant_event_to_universal(event, &session_id),
|
||||||
"tool_use" => tool_use_event_to_universal(event, &session_id),
|
"tool_use" => tool_use_event_to_universal(event, &session_id),
|
||||||
"tool_result" => tool_result_event_to_universal(event),
|
"tool_result" => tool_result_event_to_universal(event),
|
||||||
"result" => result_event_to_universal(event, &session_id),
|
"result" => result_event_to_universal(event, &session_id),
|
||||||
|
"stream_event" => stream_event_to_universal(event),
|
||||||
|
"control_request" => control_request_to_universal(event)?,
|
||||||
|
"control_response" | "keep_alive" | "update_environment_variables" => Vec::new(),
|
||||||
_ => return Err(format!("unsupported Claude event type: {event_type}")),
|
_ => return Err(format!("unsupported Claude event type: {event_type}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -85,6 +91,44 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||||
|
let is_exit_plan_mode = matches!(
|
||||||
|
name,
|
||||||
|
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||||
|
);
|
||||||
|
let is_question_tool = matches!(
|
||||||
|
name,
|
||||||
|
"AskUserQuestion" | "ask_user_question" | "askUserQuestion"
|
||||||
|
| "ask-user-question"
|
||||||
|
) || is_exit_plan_mode;
|
||||||
|
let has_question_payload = input.get("questions").is_some();
|
||||||
|
if is_question_tool || has_question_payload {
|
||||||
|
if let Some(question) = question_from_claude_input(&input, call_id.clone()) {
|
||||||
|
conversions.push(
|
||||||
|
EventConversion::new(
|
||||||
|
UniversalEventType::QuestionRequested,
|
||||||
|
UniversalEventData::Question(question),
|
||||||
|
)
|
||||||
|
.with_raw(Some(event.clone())),
|
||||||
|
);
|
||||||
|
} else if is_exit_plan_mode {
|
||||||
|
conversions.push(
|
||||||
|
EventConversion::new(
|
||||||
|
UniversalEventType::QuestionRequested,
|
||||||
|
UniversalEventData::Question(QuestionEventData {
|
||||||
|
question_id: call_id.clone(),
|
||||||
|
prompt: "Approve plan execution?".to_string(),
|
||||||
|
options: vec![
|
||||||
|
"approve".to_string(),
|
||||||
|
"reject".to_string(),
|
||||||
|
],
|
||||||
|
response: None,
|
||||||
|
status: QuestionStatus::Requested,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_raw(Some(event.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
|
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
|
||||||
let tool_item = UniversalItem {
|
let tool_item = UniversalItem {
|
||||||
item_id: String::new(),
|
item_id: String::new(),
|
||||||
|
|
@ -120,7 +164,49 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
||||||
status: ItemStatus::InProgress,
|
status: ItemStatus::InProgress,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversions.extend(message_started_events(message_item, message_parts));
|
conversions.extend(message_started_events(message_item));
|
||||||
|
conversions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||||
|
let mut conversions = Vec::new();
|
||||||
|
let content = event
|
||||||
|
.get("message")
|
||||||
|
.and_then(|msg| msg.get("content"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for block in content {
|
||||||
|
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if block_type != "tool_result" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool_use_id = block
|
||||||
|
.get("tool_use_id")
|
||||||
|
.or_else(|| block.get("toolUseId"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||||
|
let output = block.get("content").cloned().unwrap_or(Value::Null);
|
||||||
|
let output_text = serde_json::to_string(&output).unwrap_or_default();
|
||||||
|
|
||||||
|
let tool_item = UniversalItem {
|
||||||
|
item_id: next_temp_id("tmp_claude_tool_result"),
|
||||||
|
native_item_id: Some(tool_use_id.clone()),
|
||||||
|
parent_id: None,
|
||||||
|
kind: ItemKind::ToolResult,
|
||||||
|
role: Some(ItemRole::Tool),
|
||||||
|
content: vec![ContentPart::ToolResult {
|
||||||
|
call_id: tool_use_id,
|
||||||
|
output: output_text,
|
||||||
|
}],
|
||||||
|
status: ItemStatus::Completed,
|
||||||
|
};
|
||||||
|
conversions.extend(item_events(tool_item, true));
|
||||||
|
}
|
||||||
|
|
||||||
conversions
|
conversions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,10 +227,14 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||||
|
|
||||||
|
let is_exit_plan_mode = matches!(
|
||||||
|
name,
|
||||||
|
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||||
|
);
|
||||||
let is_question_tool = matches!(
|
let is_question_tool = matches!(
|
||||||
name,
|
name,
|
||||||
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
|
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
|
||||||
);
|
) || is_exit_plan_mode;
|
||||||
let has_question_payload = input.get("questions").is_some();
|
let has_question_payload = input.get("questions").is_some();
|
||||||
if is_question_tool || has_question_payload {
|
if is_question_tool || has_question_payload {
|
||||||
if let Some(question) = question_from_claude_input(&input, id.clone()) {
|
if let Some(question) = question_from_claude_input(&input, id.clone()) {
|
||||||
|
|
@ -155,6 +245,20 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
||||||
)
|
)
|
||||||
.with_raw(Some(event.clone())),
|
.with_raw(Some(event.clone())),
|
||||||
);
|
);
|
||||||
|
} else if is_exit_plan_mode {
|
||||||
|
conversions.push(
|
||||||
|
EventConversion::new(
|
||||||
|
UniversalEventType::QuestionRequested,
|
||||||
|
UniversalEventData::Question(QuestionEventData {
|
||||||
|
question_id: id.clone(),
|
||||||
|
prompt: "Approve plan execution?".to_string(),
|
||||||
|
options: vec!["approve".to_string(), "reject".to_string()],
|
||||||
|
response: None,
|
||||||
|
status: QuestionStatus::Requested,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_raw(Some(event.clone())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +330,88 @@ fn tool_result_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||||
conversions
|
conversions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||||
|
let mut conversions = Vec::new();
|
||||||
|
let Some(raw_event) = event.get("event").and_then(Value::as_object) else {
|
||||||
|
return conversions;
|
||||||
|
};
|
||||||
|
let event_type = raw_event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if event_type != "content_block_delta" {
|
||||||
|
return conversions;
|
||||||
|
}
|
||||||
|
let delta_text = raw_event
|
||||||
|
.get("delta")
|
||||||
|
.and_then(|delta| delta.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
if delta_text.is_empty() {
|
||||||
|
return conversions;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversions.push(EventConversion::new(
|
||||||
|
UniversalEventType::ItemDelta,
|
||||||
|
UniversalEventData::ItemDelta(ItemDeltaData {
|
||||||
|
item_id: String::new(),
|
||||||
|
native_item_id: None,
|
||||||
|
delta: delta_text.to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
conversions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_request_to_universal(event: &Value) -> Result<Vec<EventConversion>, String> {
|
||||||
|
let request_id = event
|
||||||
|
.get("request_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| "missing request_id".to_string())?;
|
||||||
|
let request = event
|
||||||
|
.get("request")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.ok_or_else(|| "missing request".to_string())?;
|
||||||
|
let subtype = request
|
||||||
|
.get("subtype")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if subtype != "can_use_tool" {
|
||||||
|
return Err(format!("unsupported Claude control_request subtype: {subtype}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool_name = request
|
||||||
|
.get("tool_name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||||
|
let permission_suggestions = request
|
||||||
|
.get("permission_suggestions")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let blocked_path = request
|
||||||
|
.get("blocked_path")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"toolName": tool_name,
|
||||||
|
"input": input,
|
||||||
|
"permissionSuggestions": permission_suggestions,
|
||||||
|
"blockedPath": blocked_path,
|
||||||
|
});
|
||||||
|
|
||||||
|
let permission = PermissionEventData {
|
||||||
|
permission_id: request_id.to_string(),
|
||||||
|
action: tool_name.to_string(),
|
||||||
|
status: PermissionStatus::Requested,
|
||||||
|
metadata: Some(metadata),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(vec![EventConversion::new(
|
||||||
|
UniversalEventType::PermissionRequested,
|
||||||
|
UniversalEventData::Permission(permission),
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
|
||||||
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
|
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
|
||||||
// The `result` event completes the message started by `assistant`.
|
// The `result` event completes the message started by `assistant`.
|
||||||
// Use the same native_item_id so they link to the same universal item.
|
// Use the same native_item_id so they link to the same universal item.
|
||||||
|
|
@ -285,7 +471,7 @@ fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec<EventConversio
|
||||||
|
|
||||||
/// Emits item.started + item.delta only (for `assistant` event).
|
/// Emits item.started + item.delta only (for `assistant` event).
|
||||||
/// The item.completed will come from the `result` event.
|
/// The item.completed will come from the `result` event.
|
||||||
fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<EventConversion> {
|
fn message_started_events(item: UniversalItem) -> Vec<EventConversion> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
// Emit item.started (in-progress)
|
// Emit item.started (in-progress)
|
||||||
|
|
@ -293,25 +479,6 @@ fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<E
|
||||||
UniversalEventType::ItemStarted,
|
UniversalEventType::ItemStarted,
|
||||||
UniversalEventData::Item(ItemEventData { item: item.clone() }),
|
UniversalEventData::Item(ItemEventData { item: item.clone() }),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Emit item.delta with the text content
|
|
||||||
let mut delta_text = String::new();
|
|
||||||
for part in &parts {
|
|
||||||
if let ContentPart::Text { text } = part {
|
|
||||||
delta_text.push_str(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !delta_text.is_empty() {
|
|
||||||
events.push(EventConversion::new(
|
|
||||||
UniversalEventType::ItemDelta,
|
|
||||||
UniversalEventData::ItemDelta(crate::ItemDeltaData {
|
|
||||||
item_id: item.item_id.clone(),
|
|
||||||
native_item_id: item.native_item_id.clone(),
|
|
||||||
delta: delta_text,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue