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:
Nathan Flurry 2026-01-29 07:19:10 -08:00 committed by GitHub
parent c7d6482fd4
commit 0ee60920c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 513 additions and 67 deletions

View file

@ -6,9 +6,12 @@ use crate::{
ContentPart,
EventConversion,
ItemEventData,
ItemDeltaData,
ItemKind,
ItemRole,
ItemStatus,
PermissionEventData,
PermissionStatus,
QuestionEventData,
QuestionStatus,
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 mut conversions = match event_type {
"system" => vec![system_event_to_universal(event)],
"user" => Vec::new(),
"user" => user_event_to_universal(event),
"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),
"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}")),
};
@ -85,6 +91,44 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
.and_then(Value::as_str)
.map(|s| s.to_string())
.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 tool_item = UniversalItem {
item_id: String::new(),
@ -120,7 +164,49 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
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
}
@ -141,10 +227,14 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
.map(|s| s.to_string())
.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, id.clone()) {
@ -155,6 +245,20 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
)
.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
}
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> {
// The `result` event completes the message started by `assistant`.
// 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).
/// 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();
// Emit item.started (in-progress)
@ -293,25 +479,6 @@ fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<E
UniversalEventType::ItemStarted,
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
}