feat(story-62): add permission request prompts to web UI

When Claude Code requires user approval before executing a tool (file
writes, commits, etc.) the agent sends a permission_request message
over the WebSocket.  The web UI now intercepts that message, surfaces a
modal dialog showing the tool name and input, and lets the user approve
or deny.  The decision is sent back as a permission_response, allowing
the agent to continue or adjust its approach.

Backend changes:
- claude_code.rs: parse "permission_request" NDJSON events from the PTY,
  block the PTY thread via a sync channel, and write the user's decision
  back to the PTY stdin as a JSON permission_response.
- chat.rs: thread an optional UnboundedSender<PermissionReqMsg> through
  to the provider.
- ws.rs: create a permission-request channel, forward requests to the
  client, collect responses via a pending-perms map, and interleave all
  of this with the active chat session using tokio::select!.

Frontend changes:
- client.ts: add permission_request to WsResponse, permission_response
  to WsRequest, onPermissionRequest handler to ChatWebSocket.connect(),
  and sendPermissionResponse() method.
- types.ts: mirror the same type additions.
- Chat.tsx: add permissionRequest state, wire onPermissionRequest
  callback, and render an approval modal with tool name, input context,
  Approve and Deny buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 15:56:01 +00:00
parent 5e33a0c0b3
commit e0bc4bdc90
7 changed files with 341 additions and 43 deletions

View File

@@ -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<string, unknown>;
};
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<string, unknown>,
) => 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<string, unknown>,
) => 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);

View File

@@ -38,6 +38,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
merge: [],
});
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [permissionRequest, setPermissionRequest] = useState<{
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
} | 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.",
@@ -823,6 +840,107 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div>
</div>
)}
{permissionRequest && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: "#2f2f2f",
padding: "32px",
borderRadius: "12px",
maxWidth: "520px",
width: "90%",
border: "1px solid #444",
}}
>
<h2 style={{ marginTop: 0, color: "#ececec" }}>
Permission Request
</h2>
<p
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "12px",
}}
>
The agent wants to use the{" "}
<strong style={{ color: "#ececec" }}>
{permissionRequest.toolName}
</strong>{" "}
tool. Do you approve?
</p>
{Object.keys(permissionRequest.toolInput).length > 0 && (
<pre
style={{
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "12px",
fontSize: "0.8em",
color: "#ccc",
overflowX: "auto",
maxHeight: "200px",
marginBottom: "20px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{JSON.stringify(permissionRequest.toolInput, null, 2)}
</pre>
)}
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={() => handlePermissionResponse(false)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Deny
</button>
<button
type="button"
onClick={() => handlePermissionResponse(true)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#ececec",
color: "#000",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Approve
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string, unknown>;
};
// Re-export API client types for convenience
export type {

View File

@@ -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<Message>,
config: chat::ProviderConfig,
},
Cancel,
PermissionResponse {
request_id: String,
approved: bool,
},
}
#[derive(Serialize)]
@@ -63,6 +69,12 @@ enum WsResponse {
qa: Vec<crate::http::workflow::UpcomingStory>,
merge: Vec<crate::http::workflow::UpcomingStory>,
},
/// Claude Code is requesting user approval before executing a tool.
PermissionRequest {
request_id: String,
tool_name: String,
tool_input: serde_json::Value,
},
}
impl From<WatcherEvent> for WsResponse {
@@ -139,34 +151,85 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
}
});
while let Some(Ok(msg)) = stream.next().await {
if let WsMessage::Text(text) = msg {
// Channel for permission requests flowing from the PTY thread to this handler.
let (perm_req_tx, mut perm_req_rx) =
mpsc::unbounded_channel::<crate::llm::providers::claude_code::PermissionReqMsg>();
// Map of pending permission request_id → one-shot responder.
let mut pending_perms: HashMap<String, std::sync::mpsc::SyncSender<bool>> = HashMap::new();
loop {
// Outer loop: wait for the next WebSocket message.
let Some(Ok(WsMessage::Text(text))) = stream.next().await else {
break;
};
let parsed: Result<WsRequest, _> = 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();
let result = chat::chat(
// 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(),
|history| {
move |history| {
let _ = tx_updates.send(WsResponse::Update {
messages: history.to_vec(),
});
},
|token| {
move |token| {
let _ = tx_tokens.send(WsResponse::Token {
content: token.to_string(),
});
},
)
.await;
Some(perm_tx),
);
tokio::pin!(chat_fut);
match result {
// 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::<WsRequest>(&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);
}
_ => {}
}
}
}
};
match chat_result {
Ok(chat_result) => {
if let Some(sid) = chat_result.session_id {
let _ = tx.send(WsResponse::SessionId { session_id: sid });
@@ -180,6 +243,9 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
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}"),
@@ -187,7 +253,6 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
}
}
}
}
drop(tx);
let _ = forward.await;

View File

@@ -183,6 +183,11 @@ pub async fn chat<F, U>(
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<ChatResult, String>
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}"))?;

View File

@@ -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<bool>,
}
/// 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<bool>,
mut on_token: F,
permission_tx: Option<tokio::sync::mpsc::UnboundedSender<PermissionReqMsg>>,
) -> Result<ClaudeCodeResult, String>
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<String>,
msg_tx: std::sync::mpsc::Sender<Message>,
sid_tx: tokio::sync::oneshot::Sender<String>,
permission_tx: Option<tokio::sync::mpsc::UnboundedSender<PermissionReqMsg>>,
) -> 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::<Option<String>>();
@@ -281,6 +300,53 @@ fn run_pty_session(
));
}
}
// 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);
}
}
_ => {}
}
}