fix: resolve merge conflict in claude_code.rs

Keep master's quiet system/rate_limit_event handlers while preserving
the story-62 permission_request handler (the core feature).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 16:01:22 +00:00
parent 026ba3cbcf
commit 6962e92f0c
8 changed files with 367 additions and 43 deletions

View File

@@ -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

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.",
@@ -828,6 +845,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>>();
@@ -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);
}
}
_ => {}
}
}