diff --git a/.story_kit/work/2_current/62_story_allow_frontend_ui_to_accept_permissions_requests.md b/.story_kit/work/2_current/62_story_allow_frontend_ui_to_accept_permissions_requests.md new file mode 100644 index 0000000..c7473f6 --- /dev/null +++ b/.story_kit/work/2_current/62_story_allow_frontend_ui_to_accept_permissions_requests.md @@ -0,0 +1,26 @@ +--- +name: Agent Permission Prompts in Web UI +test_plan: pending +--- + +# Story 62: Agent Permission Prompts in Web UI + +## User Story + +As a user interacting with an agent through the web UI, I want to be prompted for permission approvals (e.g. file writes, commits) so that the agent can complete tasks that require elevated permissions without getting blocked. + +Right now, the web UI does not have any way of the user allowing permissions requests, so dev processes are basically blocked. + +## Acceptance Criteria + +- [ ] When an agent action requires permission (e.g. writing to a file, committing), the web UI surfaces a prompt to the user +- [ ] The user can approve or deny the permission request from the UI +- [ ] On approval, the agent continues with the requested action +- [ ] On denial, the agent receives the denial and adjusts its approach +- [ ] Permission prompts display enough context (file path, action type) for the user to make an informed decision + + +## Out of Scope + +- Bulk/blanket permission grants (e.g. "allow all writes to this directory") +- Persisting permission decisions across sessions diff --git a/.story_kit/work/2_current/83_story_remove_active_work_list_from_agents_panel.md b/.story_kit/work/3_qa/83_story_remove_active_work_list_from_agents_panel.md similarity index 100% rename from .story_kit/work/2_current/83_story_remove_active_work_list_from_agents_panel.md rename to .story_kit/work/3_qa/83_story_remove_active_work_list_from_agents_panel.md diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6dffef5..c951caa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,6 +6,11 @@ export type WsRequest = } | { type: "cancel"; + } + | { + type: "permission_response"; + request_id: string; + approved: boolean; }; export interface AgentAssignment { @@ -39,6 +44,12 @@ export type WsResponse = current: PipelineStageItem[]; qa: PipelineStageItem[]; merge: PipelineStageItem[]; + } + | { + type: "permission_request"; + request_id: string; + tool_name: string; + tool_input: Record; }; export interface ProviderConfig { @@ -244,6 +255,11 @@ export class ChatWebSocket { private onSessionId?: (sessionId: string) => void; private onError?: (message: string) => void; private onPipelineState?: (state: PipelineState) => void; + private onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -280,6 +296,12 @@ export class ChatWebSocket { qa: data.qa, merge: data.merge, }); + if (data.type === "permission_request") + this.onPermissionRequest?.( + data.request_id, + data.tool_name, + data.tool_input, + ); } catch (err) { this.onError?.(String(err)); } @@ -314,6 +336,11 @@ export class ChatWebSocket { onSessionId?: (sessionId: string) => void; onError?: (message: string) => void; onPipelineState?: (state: PipelineState) => void; + onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -322,6 +349,7 @@ export class ChatWebSocket { this.onSessionId = handlers.onSessionId; this.onError = handlers.onError; this.onPipelineState = handlers.onPipelineState; + this.onPermissionRequest = handlers.onPermissionRequest; this.wsPath = wsPath; this.shouldReconnect = true; @@ -351,6 +379,10 @@ export class ChatWebSocket { this.send({ type: "cancel" }); } + sendPermissionResponse(requestId: string, approved: boolean) { + this.send({ type: "permission_response", request_id: requestId, approved }); + } + close() { this.shouldReconnect = false; window.clearTimeout(this.reconnectTimer); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 5d9a6a2..12d0b9e 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -38,6 +38,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { merge: [], }); const [claudeSessionId, setClaudeSessionId] = useState(null); + const [permissionRequest, setPermissionRequest] = useState<{ + requestId: string; + toolName: string; + toolInput: Record; + } | null>(null); const [isNarrowScreen, setIsNarrowScreen] = useState( window.innerWidth < NARROW_BREAKPOINT, ); @@ -166,6 +171,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onPipelineState: (state) => { setPipeline(state); }, + onPermissionRequest: (requestId, toolName, toolInput) => { + setPermissionRequest({ requestId, toolName, toolInput }); + }, }); return () => { @@ -319,6 +327,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } }; + const handlePermissionResponse = (approved: boolean) => { + if (!permissionRequest) return; + wsRef.current?.sendPermissionResponse( + permissionRequest.requestId, + approved, + ); + setPermissionRequest(null); + }; + const clearSession = async () => { const confirmed = window.confirm( "Are you sure? This will clear all messages and reset the conversation context.", @@ -828,6 +845,107 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { )} + {permissionRequest && ( +
+
+

+ Permission Request +

+

+ The agent wants to use the{" "} + + {permissionRequest.toolName} + {" "} + tool. Do you approve? +

+ {Object.keys(permissionRequest.toolInput).length > 0 && ( +
+								{JSON.stringify(permissionRequest.toolInput, null, 2)}
+							
+ )} +
+ + +
+
+
+ )} ); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a78e838..b5df52c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -48,13 +48,24 @@ export type WsRequest = } | { type: "cancel"; + } + | { + type: "permission_response"; + request_id: string; + approved: boolean; }; export type WsResponse = | { type: "token"; content: string } | { type: "update"; messages: Message[] } | { type: "session_id"; session_id: string } - | { type: "error"; message: string }; + | { type: "error"; message: string } + | { + type: "permission_request"; + request_id: string; + tool_name: string; + tool_input: Record; + }; // Re-export API client types for convenience export type { diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 42d4578..a0f6004 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -8,6 +8,7 @@ use poem::handler; use poem::web::Data; use poem::web::websocket::{Message as WsMessage, WebSocket}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::mpsc; @@ -17,12 +18,17 @@ use tokio::sync::mpsc; /// /// - `chat` starts a streaming chat session. /// - `cancel` stops the active session. +/// - `permission_response` approves or denies a pending permission request. enum WsRequest { Chat { messages: Vec, config: chat::ProviderConfig, }, Cancel, + PermissionResponse { + request_id: String, + approved: bool, + }, } #[derive(Serialize)] @@ -63,6 +69,12 @@ enum WsResponse { qa: Vec, merge: Vec, }, + /// Claude Code is requesting user approval before executing a tool. + PermissionRequest { + request_id: String, + tool_name: String, + tool_input: serde_json::Value, + }, } impl From for WsResponse { @@ -139,52 +151,105 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem } }); - while let Some(Ok(msg)) = stream.next().await { - if let WsMessage::Text(text) = msg { - let parsed: Result = serde_json::from_str(&text); - match parsed { - Ok(WsRequest::Chat { messages, config }) => { - let tx_updates = tx.clone(); - let tx_tokens = tx.clone(); - let ctx_clone = ctx.clone(); + // Channel for permission requests flowing from the PTY thread to this handler. + let (perm_req_tx, mut perm_req_rx) = + mpsc::unbounded_channel::(); + // Map of pending permission request_id → one-shot responder. + let mut pending_perms: HashMap> = HashMap::new(); - let result = chat::chat( - messages, - config, - &ctx_clone.state, - ctx_clone.store.as_ref(), - |history| { - let _ = tx_updates.send(WsResponse::Update { - messages: history.to_vec(), - }); - }, - |token| { - let _ = tx_tokens.send(WsResponse::Token { - content: token.to_string(), - }); - }, - ) - .await; + loop { + // Outer loop: wait for the next WebSocket message. + let Some(Ok(WsMessage::Text(text))) = stream.next().await else { + break; + }; - match result { - Ok(chat_result) => { - if let Some(sid) = chat_result.session_id { - let _ = tx.send(WsResponse::SessionId { session_id: sid }); + let parsed: Result = serde_json::from_str(&text); + match parsed { + Ok(WsRequest::Chat { messages, config }) => { + let tx_updates = tx.clone(); + let tx_tokens = tx.clone(); + let ctx_clone = ctx.clone(); + let perm_tx = perm_req_tx.clone(); + + // Build the chat future without driving it yet so we can + // interleave it with permission-request forwarding. + let chat_fut = chat::chat( + messages, + config, + &ctx_clone.state, + ctx_clone.store.as_ref(), + move |history| { + let _ = tx_updates.send(WsResponse::Update { + messages: history.to_vec(), + }); + }, + move |token| { + let _ = tx_tokens.send(WsResponse::Token { + content: token.to_string(), + }); + }, + Some(perm_tx), + ); + tokio::pin!(chat_fut); + + // Inner loop: drive the chat while concurrently handling + // permission requests and WebSocket messages. + let chat_result = loop { + tokio::select! { + result = &mut chat_fut => break result, + + // Forward permission requests from PTY to the client. + Some(perm_req) = perm_req_rx.recv() => { + let _ = tx.send(WsResponse::PermissionRequest { + request_id: perm_req.request_id.clone(), + tool_name: perm_req.tool_name.clone(), + tool_input: perm_req.tool_input.clone(), + }); + pending_perms.insert( + perm_req.request_id, + perm_req.response_tx, + ); + } + + // Handle WebSocket messages during an active chat + // (permission responses and cancellations). + Some(Ok(WsMessage::Text(inner_text))) = stream.next() => { + match serde_json::from_str::(&inner_text) { + Ok(WsRequest::PermissionResponse { request_id, approved }) => { + if let Some(resp_tx) = pending_perms.remove(&request_id) { + let _ = resp_tx.send(approved); + } + } + Ok(WsRequest::Cancel) => { + let _ = chat::cancel_chat(&ctx.state); + } + _ => {} } } - Err(err) => { - let _ = tx.send(WsResponse::Error { message: err }); + } + }; + + match chat_result { + Ok(chat_result) => { + if let Some(sid) = chat_result.session_id { + let _ = tx.send(WsResponse::SessionId { session_id: sid }); } } + Err(err) => { + let _ = tx.send(WsResponse::Error { message: err }); + } } - Ok(WsRequest::Cancel) => { - let _ = chat::cancel_chat(&ctx.state); - } - Err(err) => { - let _ = tx.send(WsResponse::Error { - message: format!("Invalid request: {err}"), - }); - } + } + Ok(WsRequest::Cancel) => { + let _ = chat::cancel_chat(&ctx.state); + } + Ok(WsRequest::PermissionResponse { .. }) => { + // Permission responses outside an active chat are ignored. + } + Err(err) => { + let _ = tx.send(WsResponse::Error { + message: format!("Invalid request: {err}"), + }); } } } diff --git a/server/src/llm/chat.rs b/server/src/llm/chat.rs index 5605587..a32f0bd 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat.rs @@ -183,6 +183,11 @@ pub async fn chat( store: &dyn StoreOps, mut on_update: F, mut on_token: U, + permission_tx: Option< + tokio::sync::mpsc::UnboundedSender< + crate::llm::providers::claude_code::PermissionReqMsg, + >, + >, ) -> Result where F: FnMut(&[Message]) + Send, @@ -238,6 +243,7 @@ where config.session_id.as_deref(), &mut cancel_rx, |token| on_token(token), + permission_tx, ) .await .map_err(|e| format!("Claude Code Error: {e}"))?; diff --git a/server/src/llm/providers/claude_code.rs b/server/src/llm/providers/claude_code.rs index b551014..0b63fbc 100644 --- a/server/src/llm/providers/claude_code.rs +++ b/server/src/llm/providers/claude_code.rs @@ -1,11 +1,23 @@ use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::watch; use crate::llm::types::{FunctionCall, Message, Role, ToolCall}; +/// A permission request emitted by Claude Code that must be resolved by the user. +pub struct PermissionReqMsg { + /// Unique identifier for this request (used to correlate the response). + pub request_id: String, + /// The tool that is requesting permission (e.g. "Bash", "Write"). + pub tool_name: String, + /// The tool's input arguments (e.g. `{"command": "git push"}`). + pub tool_input: serde_json::Value, + /// One-shot channel to send the user's decision back to the PTY thread. + pub response_tx: std::sync::mpsc::SyncSender, +} + /// Result from a Claude Code session containing structured messages. pub struct ClaudeCodeResult { /// The conversation messages produced by Claude Code, including assistant @@ -38,6 +50,7 @@ impl ClaudeCodeProvider { session_id: Option<&str>, cancel_rx: &mut watch::Receiver, mut on_token: F, + permission_tx: Option>, ) -> Result where F: FnMut(&str) + Send, @@ -71,6 +84,7 @@ impl ClaudeCodeProvider { token_tx, msg_tx, sid_tx, + permission_tx, ) }); @@ -100,6 +114,7 @@ impl ClaudeCodeProvider { /// Sends streaming text tokens via `token_tx` for real-time display, and /// complete structured `Message` values via `msg_tx` for the final message /// history (assistant turns with tool_calls, and tool result turns). +#[allow(clippy::too_many_arguments)] fn run_pty_session( user_message: &str, cwd: &str, @@ -108,6 +123,7 @@ fn run_pty_session( token_tx: tokio::sync::mpsc::UnboundedSender, msg_tx: std::sync::mpsc::Sender, sid_tx: tokio::sync::oneshot::Sender, + permission_tx: Option>, ) -> Result<(), String> { let pty_system = native_pty_system(); @@ -160,8 +176,11 @@ fn run_pty_session( .try_clone_reader() .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; - // We don't need to write anything — -p mode takes prompt as arg - drop(pair.master); + // Keep a writer handle so we can respond to permission_request events. + let mut pty_writer = pair + .master + .take_writer() + .map_err(|e| format!("Failed to take PTY writer: {e}"))?; // Read NDJSON lines from stdout let (line_tx, line_rx) = std::sync::mpsc::channel::>(); @@ -256,6 +275,53 @@ fn run_pty_session( "system" => {} // Rate limit info — suppress noisy notification "rate_limit_event" => {} + // Claude Code is requesting user approval before executing a tool. + // Forward the request to the async context via permission_tx and + // block until the user responds (or a 5-minute timeout elapses). + "permission_request" => { + let request_id = json + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = json + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let tool_input = json + .get("input") + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + + if let Some(ref ptx) = permission_tx { + let (resp_tx, resp_rx) = std::sync::mpsc::sync_channel(1); + let _ = ptx.send(PermissionReqMsg { + request_id: request_id.clone(), + tool_name, + tool_input, + response_tx: resp_tx, + }); + // Block until the user responds or a 5-minute timeout elapses. + let approved = resp_rx + .recv_timeout(std::time::Duration::from_secs(300)) + .unwrap_or(false); + let response = serde_json::json!({ + "type": "permission_response", + "id": request_id, + "approved": approved, + }); + let _ = writeln!(pty_writer, "{}", response); + } else { + // No handler configured — deny by default. + let response = serde_json::json!({ + "type": "permission_response", + "id": request_id, + "approved": false, + }); + let _ = writeln!(pty_writer, "{}", response); + } + } _ => {} } }