@@ -690,6 +690,21 @@ impl SessionState {
690690
691691 self . update_pending ( & event) ;
692692 self . update_item_tracking ( & event) ;
693+
694+ // Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) from frontends.
695+ // The permission is still stored in pending_permissions (via update_pending above)
696+ // so reply_question/reject_question can find and resolve it internally.
697+ if matches ! (
698+ event. event_type,
699+ UniversalEventType :: PermissionRequested | UniversalEventType :: PermissionResolved
700+ ) {
701+ if let UniversalEventData :: Permission ( ref data) = event. data {
702+ if is_question_tool_action ( & data. action ) {
703+ return None ;
704+ }
705+ }
706+ }
707+
693708 self . events . push ( event. clone ( ) ) ;
694709 let _ = self . broadcaster . send ( event. clone ( ) ) ;
695710 if self . native_session_id . is_none ( ) {
@@ -767,6 +782,17 @@ impl SessionState {
767782 self . pending_permissions . remove ( permission_id)
768783 }
769784
785+ /// Find and remove a pending permission whose action matches a question tool
786+ /// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
787+ fn take_question_tool_permission ( & mut self ) -> Option < ( String , PendingPermission ) > {
788+ let key = self
789+ . pending_permissions
790+ . iter ( )
791+ . find ( |( _, p) | is_question_tool_action ( & p. action ) )
792+ . map ( |( k, _) | k. clone ( ) ) ;
793+ key. and_then ( |k| self . pending_permissions . remove ( & k) . map ( |p| ( k, p) ) )
794+ }
795+
770796 fn mark_ended (
771797 & mut self ,
772798 exit_code : Option < i32 > ,
@@ -2117,7 +2143,7 @@ impl SessionManager {
21172143 question_id : & str ,
21182144 answers : Vec < Vec < String > > ,
21192145 ) -> Result < ( ) , SandboxError > {
2120- let ( agent, native_session_id, pending_question, claude_sender) = {
2146+ let ( agent, native_session_id, pending_question, claude_sender, linked_permission ) = {
21212147 let mut sessions = self . sessions . lock ( ) . await ;
21222148 let session = Self :: session_mut ( & mut sessions, session_id) . ok_or_else ( || {
21232149 SandboxError :: SessionNotFound {
@@ -2133,11 +2159,18 @@ impl SessionManager {
21332159 if let Some ( err) = session. ended_error ( ) {
21342160 return Err ( err) ;
21352161 }
2162+ // For Claude, check if there's a linked AskUserQuestion/ExitPlanMode permission
2163+ let linked_perm = if session. agent == AgentId :: Claude {
2164+ session. take_question_tool_permission ( )
2165+ } else {
2166+ None
2167+ } ;
21362168 (
21372169 session. agent ,
21382170 session. native_session_id . clone ( ) ,
21392171 pending,
21402172 session. claude_sender ( ) ,
2173+ linked_perm,
21412174 )
21422175 } ;
21432176
@@ -2150,28 +2183,67 @@ impl SessionManager {
21502183 . ok_or_else ( || SandboxError :: InvalidRequest {
21512184 message : "missing OpenCode session id" . to_string ( ) ,
21522185 } ) ?;
2153- self . opencode_question_reply ( & agent_session_id, question_id, answers)
2186+ self . opencode_question_reply ( & agent_session_id, question_id, answers. clone ( ) )
21542187 . await ?;
21552188 } else if agent == AgentId :: Claude {
21562189 let sender = claude_sender. ok_or_else ( || SandboxError :: InvalidRequest {
21572190 message : "Claude session is not active" . to_string ( ) ,
21582191 } ) ?;
2159- let session_id = native_session_id
2160- . clone ( )
2161- . unwrap_or_else ( || session_id. to_string ( ) ) ;
2162- let response_text = response. clone ( ) . unwrap_or_default ( ) ;
2163- let line = claude_tool_result_line ( & session_id, question_id, & response_text, false ) ;
2164- sender
2165- . send ( line)
2166- . map_err ( |_| SandboxError :: InvalidRequest {
2167- message : "Claude session is not active" . to_string ( ) ,
2168- } ) ?;
2192+ if let Some ( ( perm_id, perm) ) = & linked_permission {
2193+ // Use the permission control response to deliver the answer.
2194+ // Build updatedInput from the original input with the answers map added.
2195+ let original_input = perm
2196+ . metadata
2197+ . as_ref ( )
2198+ . and_then ( |m| m. get ( "input" ) )
2199+ . cloned ( )
2200+ . unwrap_or ( Value :: Null ) ;
2201+ let mut updated = match original_input {
2202+ Value :: Object ( map) => map,
2203+ _ => serde_json:: Map :: new ( ) ,
2204+ } ;
2205+ // Build answers map: { "0": "selected option", "1": "another option", ... }
2206+ let answers_map: serde_json:: Map < String , Value > = answers
2207+ . iter ( )
2208+ . enumerate ( )
2209+ . filter_map ( |( i, inner) | {
2210+ inner
2211+ . first ( )
2212+ . map ( |v| ( i. to_string ( ) , Value :: String ( v. clone ( ) ) ) )
2213+ } )
2214+ . collect ( ) ;
2215+ updated. insert ( "answers" . to_string ( ) , Value :: Object ( answers_map) ) ;
2216+
2217+ let mut response_map = serde_json:: Map :: new ( ) ;
2218+ response_map. insert ( "updatedInput" . to_string ( ) , Value :: Object ( updated) ) ;
2219+ let line =
2220+ claude_control_response_line ( perm_id, "allow" , Value :: Object ( response_map) ) ;
2221+ sender
2222+ . send ( line)
2223+ . map_err ( |_| SandboxError :: InvalidRequest {
2224+ message : "Claude session is not active" . to_string ( ) ,
2225+ } ) ?;
2226+ } else {
2227+ // No linked permission — fall back to tool_result
2228+ let native_sid = native_session_id
2229+ . clone ( )
2230+ . unwrap_or_else ( || session_id. to_string ( ) ) ;
2231+ let response_text = response. clone ( ) . unwrap_or_default ( ) ;
2232+ let line =
2233+ claude_tool_result_line ( & native_sid, question_id, & response_text, false ) ;
2234+ sender
2235+ . send ( line)
2236+ . map_err ( |_| SandboxError :: InvalidRequest {
2237+ message : "Claude session is not active" . to_string ( ) ,
2238+ } ) ?;
2239+ }
21692240 } else {
21702241 // TODO: Forward question replies to subprocess agents.
21712242 }
21722243
2244+ // Emit QuestionResolved
21732245 if let Some ( pending) = pending_question {
2174- let resolved = EventConversion :: new (
2246+ let mut conversions = vec ! [ EventConversion :: new(
21752247 UniversalEventType :: QuestionResolved ,
21762248 UniversalEventData :: Question ( QuestionEventData {
21772249 question_id: question_id. to_string( ) ,
@@ -2182,8 +2254,26 @@ impl SessionManager {
21822254 } ) ,
21832255 )
21842256 . synthetic( )
2185- . with_native_session ( native_session_id) ;
2186- let _ = self . record_conversions ( session_id, vec ! [ resolved] ) . await ;
2257+ . with_native_session( native_session_id. clone( ) ) ] ;
2258+
2259+ // Also emit PermissionResolved for the linked permission
2260+ if let Some ( ( perm_id, perm) ) = linked_permission {
2261+ conversions. push (
2262+ EventConversion :: new (
2263+ UniversalEventType :: PermissionResolved ,
2264+ UniversalEventData :: Permission ( PermissionEventData {
2265+ permission_id : perm_id,
2266+ action : perm. action ,
2267+ status : PermissionStatus :: Approved ,
2268+ metadata : perm. metadata ,
2269+ } ) ,
2270+ )
2271+ . synthetic ( )
2272+ . with_native_session ( native_session_id) ,
2273+ ) ;
2274+ }
2275+
2276+ let _ = self . record_conversions ( session_id, conversions) . await ;
21872277 }
21882278
21892279 Ok ( ( ) )
@@ -2194,7 +2284,7 @@ impl SessionManager {
21942284 session_id : & str ,
21952285 question_id : & str ,
21962286 ) -> Result < ( ) , SandboxError > {
2197- let ( agent, native_session_id, pending_question, claude_sender) = {
2287+ let ( agent, native_session_id, pending_question, claude_sender, linked_permission ) = {
21982288 let mut sessions = self . sessions . lock ( ) . await ;
21992289 let session = Self :: session_mut ( & mut sessions, session_id) . ok_or_else ( || {
22002290 SandboxError :: SessionNotFound {
@@ -2210,11 +2300,17 @@ impl SessionManager {
22102300 if let Some ( err) = session. ended_error ( ) {
22112301 return Err ( err) ;
22122302 }
2303+ let linked_perm = if session. agent == AgentId :: Claude {
2304+ session. take_question_tool_permission ( )
2305+ } else {
2306+ None
2307+ } ;
22132308 (
22142309 session. agent ,
22152310 session. native_session_id . clone ( ) ,
22162311 pending,
22172312 session. claude_sender ( ) ,
2313+ linked_perm,
22182314 )
22192315 } ;
22202316
@@ -2231,26 +2327,43 @@ impl SessionManager {
22312327 let sender = claude_sender. ok_or_else ( || SandboxError :: InvalidRequest {
22322328 message : "Claude session is not active" . to_string ( ) ,
22332329 } ) ?;
2234- let session_id = native_session_id
2235- . clone ( )
2236- . unwrap_or_else ( || session_id. to_string ( ) ) ;
2237- let line = claude_tool_result_line (
2238- & session_id,
2239- question_id,
2240- "User rejected the question." ,
2241- true ,
2242- ) ;
2243- sender
2244- . send ( line)
2245- . map_err ( |_| SandboxError :: InvalidRequest {
2246- message : "Claude session is not active" . to_string ( ) ,
2247- } ) ?;
2330+ if let Some ( ( perm_id, _) ) = & linked_permission {
2331+ // Deny via the permission control response
2332+ let mut response_map = serde_json:: Map :: new ( ) ;
2333+ response_map. insert (
2334+ "message" . to_string ( ) ,
2335+ Value :: String ( "Permission denied." . to_string ( ) ) ,
2336+ ) ;
2337+ let line =
2338+ claude_control_response_line ( perm_id, "deny" , Value :: Object ( response_map) ) ;
2339+ sender
2340+ . send ( line)
2341+ . map_err ( |_| SandboxError :: InvalidRequest {
2342+ message : "Claude session is not active" . to_string ( ) ,
2343+ } ) ?;
2344+ } else {
2345+ let native_sid = native_session_id
2346+ . clone ( )
2347+ . unwrap_or_else ( || session_id. to_string ( ) ) ;
2348+ let line = claude_tool_result_line (
2349+ & native_sid,
2350+ question_id,
2351+ "User rejected the question." ,
2352+ true ,
2353+ ) ;
2354+ sender
2355+ . send ( line)
2356+ . map_err ( |_| SandboxError :: InvalidRequest {
2357+ message : "Claude session is not active" . to_string ( ) ,
2358+ } ) ?;
2359+ }
22482360 } else {
22492361 // TODO: Forward question rejections to subprocess agents.
22502362 }
22512363
2364+ // Emit QuestionResolved
22522365 if let Some ( pending) = pending_question {
2253- let resolved = EventConversion :: new (
2366+ let mut conversions = vec ! [ EventConversion :: new(
22542367 UniversalEventType :: QuestionResolved ,
22552368 UniversalEventData :: Question ( QuestionEventData {
22562369 question_id: question_id. to_string( ) ,
@@ -2261,8 +2374,26 @@ impl SessionManager {
22612374 } ) ,
22622375 )
22632376 . synthetic( )
2264- . with_native_session ( native_session_id) ;
2265- let _ = self . record_conversions ( session_id, vec ! [ resolved] ) . await ;
2377+ . with_native_session( native_session_id. clone( ) ) ] ;
2378+
2379+ // Also emit PermissionResolved for the linked permission
2380+ if let Some ( ( perm_id, perm) ) = linked_permission {
2381+ conversions. push (
2382+ EventConversion :: new (
2383+ UniversalEventType :: PermissionResolved ,
2384+ UniversalEventData :: Permission ( PermissionEventData {
2385+ permission_id : perm_id,
2386+ action : perm. action ,
2387+ status : PermissionStatus :: Denied ,
2388+ metadata : perm. metadata ,
2389+ } ) ,
2390+ )
2391+ . synthetic ( )
2392+ . with_native_session ( native_session_id) ,
2393+ ) ;
2394+ }
2395+
2396+ let _ = self . record_conversions ( session_id, conversions) . await ;
22662397 }
22672398
22682399 Ok ( ( ) )
@@ -5076,6 +5207,22 @@ fn claude_control_response_line(request_id: &str, behavior: &str, response: Valu
50765207 . to_string ( )
50775208}
50785209
5210+ /// Returns true if the given action name corresponds to a question tool
5211+ /// (AskUserQuestion or ExitPlanMode in any casing convention).
5212+ pub ( crate ) fn is_question_tool_action ( action : & str ) -> bool {
5213+ matches ! (
5214+ action,
5215+ "AskUserQuestion"
5216+ | "ask_user_question"
5217+ | "askUserQuestion"
5218+ | "ask-user-question"
5219+ | "ExitPlanMode"
5220+ | "exit_plan_mode"
5221+ | "exitPlanMode"
5222+ | "exit-plan-mode"
5223+ )
5224+ }
5225+
50795226fn read_lines < R : std:: io:: Read > ( reader : R , sender : mpsc:: UnboundedSender < String > ) {
50805227 let mut reader = BufReader :: new ( reader) ;
50815228 let mut line = String :: new ( ) ;
0 commit comments