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:
@@ -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
|
||||||
@@ -6,6 +6,11 @@ export type WsRequest =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "cancel";
|
type: "cancel";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_response";
|
||||||
|
request_id: string;
|
||||||
|
approved: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentAssignment {
|
export interface AgentAssignment {
|
||||||
@@ -39,6 +44,12 @@ export type WsResponse =
|
|||||||
current: PipelineStageItem[];
|
current: PipelineStageItem[];
|
||||||
qa: PipelineStageItem[];
|
qa: PipelineStageItem[];
|
||||||
merge: PipelineStageItem[];
|
merge: PipelineStageItem[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_request";
|
||||||
|
request_id: string;
|
||||||
|
tool_name: string;
|
||||||
|
tool_input: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
@@ -244,6 +255,11 @@ export class ChatWebSocket {
|
|||||||
private onSessionId?: (sessionId: string) => void;
|
private onSessionId?: (sessionId: string) => void;
|
||||||
private onError?: (message: string) => void;
|
private onError?: (message: string) => void;
|
||||||
private onPipelineState?: (state: PipelineState) => void;
|
private onPipelineState?: (state: PipelineState) => void;
|
||||||
|
private onPermissionRequest?: (
|
||||||
|
requestId: string,
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
@@ -280,6 +296,12 @@ export class ChatWebSocket {
|
|||||||
qa: data.qa,
|
qa: data.qa,
|
||||||
merge: data.merge,
|
merge: data.merge,
|
||||||
});
|
});
|
||||||
|
if (data.type === "permission_request")
|
||||||
|
this.onPermissionRequest?.(
|
||||||
|
data.request_id,
|
||||||
|
data.tool_name,
|
||||||
|
data.tool_input,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.onError?.(String(err));
|
this.onError?.(String(err));
|
||||||
}
|
}
|
||||||
@@ -314,6 +336,11 @@ export class ChatWebSocket {
|
|||||||
onSessionId?: (sessionId: string) => void;
|
onSessionId?: (sessionId: string) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
onPipelineState?: (state: PipelineState) => void;
|
onPipelineState?: (state: PipelineState) => void;
|
||||||
|
onPermissionRequest?: (
|
||||||
|
requestId: string,
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
@@ -322,6 +349,7 @@ export class ChatWebSocket {
|
|||||||
this.onSessionId = handlers.onSessionId;
|
this.onSessionId = handlers.onSessionId;
|
||||||
this.onError = handlers.onError;
|
this.onError = handlers.onError;
|
||||||
this.onPipelineState = handlers.onPipelineState;
|
this.onPipelineState = handlers.onPipelineState;
|
||||||
|
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
@@ -351,6 +379,10 @@ export class ChatWebSocket {
|
|||||||
this.send({ type: "cancel" });
|
this.send({ type: "cancel" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendPermissionResponse(requestId: string, approved: boolean) {
|
||||||
|
this.send({ type: "permission_response", request_id: requestId, approved });
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
window.clearTimeout(this.reconnectTimer);
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
merge: [],
|
merge: [],
|
||||||
});
|
});
|
||||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
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(
|
const [isNarrowScreen, setIsNarrowScreen] = useState(
|
||||||
window.innerWidth < NARROW_BREAKPOINT,
|
window.innerWidth < NARROW_BREAKPOINT,
|
||||||
);
|
);
|
||||||
@@ -166,6 +171,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onPipelineState: (state) => {
|
onPipelineState: (state) => {
|
||||||
setPipeline(state);
|
setPipeline(state);
|
||||||
},
|
},
|
||||||
|
onPermissionRequest: (requestId, toolName, toolInput) => {
|
||||||
|
setPermissionRequest({ requestId, toolName, toolInput });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
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 clearSession = async () => {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"Are you sure? This will clear all messages and reset the conversation context.",
|
"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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,13 +48,24 @@ export type WsRequest =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "cancel";
|
type: "cancel";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_response";
|
||||||
|
request_id: string;
|
||||||
|
approved: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WsResponse =
|
export type WsResponse =
|
||||||
| { type: "token"; content: string }
|
| { type: "token"; content: string }
|
||||||
| { type: "update"; messages: Message[] }
|
| { type: "update"; messages: Message[] }
|
||||||
| { type: "session_id"; session_id: string }
|
| { 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
|
// Re-export API client types for convenience
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use poem::handler;
|
|||||||
use poem::web::Data;
|
use poem::web::Data;
|
||||||
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -17,12 +18,17 @@ use tokio::sync::mpsc;
|
|||||||
///
|
///
|
||||||
/// - `chat` starts a streaming chat session.
|
/// - `chat` starts a streaming chat session.
|
||||||
/// - `cancel` stops the active session.
|
/// - `cancel` stops the active session.
|
||||||
|
/// - `permission_response` approves or denies a pending permission request.
|
||||||
enum WsRequest {
|
enum WsRequest {
|
||||||
Chat {
|
Chat {
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
config: chat::ProviderConfig,
|
config: chat::ProviderConfig,
|
||||||
},
|
},
|
||||||
Cancel,
|
Cancel,
|
||||||
|
PermissionResponse {
|
||||||
|
request_id: String,
|
||||||
|
approved: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -63,6 +69,12 @@ enum WsResponse {
|
|||||||
qa: Vec<crate::http::workflow::UpcomingStory>,
|
qa: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
merge: 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 {
|
impl From<WatcherEvent> for WsResponse {
|
||||||
@@ -139,52 +151,105 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
while let Some(Ok(msg)) = stream.next().await {
|
// Channel for permission requests flowing from the PTY thread to this handler.
|
||||||
if let WsMessage::Text(text) = msg {
|
let (perm_req_tx, mut perm_req_rx) =
|
||||||
let parsed: Result<WsRequest, _> = serde_json::from_str(&text);
|
mpsc::unbounded_channel::<crate::llm::providers::claude_code::PermissionReqMsg>();
|
||||||
match parsed {
|
// Map of pending permission request_id → one-shot responder.
|
||||||
Ok(WsRequest::Chat { messages, config }) => {
|
let mut pending_perms: HashMap<String, std::sync::mpsc::SyncSender<bool>> = HashMap::new();
|
||||||
let tx_updates = tx.clone();
|
|
||||||
let tx_tokens = tx.clone();
|
|
||||||
let ctx_clone = ctx.clone();
|
|
||||||
|
|
||||||
let result = chat::chat(
|
loop {
|
||||||
messages,
|
// Outer loop: wait for the next WebSocket message.
|
||||||
config,
|
let Some(Ok(WsMessage::Text(text))) = stream.next().await else {
|
||||||
&ctx_clone.state,
|
break;
|
||||||
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;
|
|
||||||
|
|
||||||
match result {
|
let parsed: Result<WsRequest, _> = serde_json::from_str(&text);
|
||||||
Ok(chat_result) => {
|
match parsed {
|
||||||
if let Some(sid) = chat_result.session_id {
|
Ok(WsRequest::Chat { messages, config }) => {
|
||||||
let _ = tx.send(WsResponse::SessionId { session_id: sid });
|
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::<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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
Ok(WsRequest::Cancel) => {
|
||||||
}
|
let _ = chat::cancel_chat(&ctx.state);
|
||||||
Err(err) => {
|
}
|
||||||
let _ = tx.send(WsResponse::Error {
|
Ok(WsRequest::PermissionResponse { .. }) => {
|
||||||
message: format!("Invalid request: {err}"),
|
// Permission responses outside an active chat are ignored.
|
||||||
});
|
}
|
||||||
}
|
Err(err) => {
|
||||||
|
let _ = tx.send(WsResponse::Error {
|
||||||
|
message: format!("Invalid request: {err}"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ pub async fn chat<F, U>(
|
|||||||
store: &dyn StoreOps,
|
store: &dyn StoreOps,
|
||||||
mut on_update: F,
|
mut on_update: F,
|
||||||
mut on_token: U,
|
mut on_token: U,
|
||||||
|
permission_tx: Option<
|
||||||
|
tokio::sync::mpsc::UnboundedSender<
|
||||||
|
crate::llm::providers::claude_code::PermissionReqMsg,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
) -> Result<ChatResult, String>
|
) -> Result<ChatResult, String>
|
||||||
where
|
where
|
||||||
F: FnMut(&[Message]) + Send,
|
F: FnMut(&[Message]) + Send,
|
||||||
@@ -238,6 +243,7 @@ where
|
|||||||
config.session_id.as_deref(),
|
config.session_id.as_deref(),
|
||||||
&mut cancel_rx,
|
&mut cancel_rx,
|
||||||
|token| on_token(token),
|
|token| on_token(token),
|
||||||
|
permission_tx,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Claude Code Error: {e}"))?;
|
.map_err(|e| format!("Claude Code Error: {e}"))?;
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
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::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use crate::llm::types::{FunctionCall, Message, Role, ToolCall};
|
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.
|
/// Result from a Claude Code session containing structured messages.
|
||||||
pub struct ClaudeCodeResult {
|
pub struct ClaudeCodeResult {
|
||||||
/// The conversation messages produced by Claude Code, including assistant
|
/// The conversation messages produced by Claude Code, including assistant
|
||||||
@@ -38,6 +50,7 @@ impl ClaudeCodeProvider {
|
|||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
cancel_rx: &mut watch::Receiver<bool>,
|
cancel_rx: &mut watch::Receiver<bool>,
|
||||||
mut on_token: F,
|
mut on_token: F,
|
||||||
|
permission_tx: Option<tokio::sync::mpsc::UnboundedSender<PermissionReqMsg>>,
|
||||||
) -> Result<ClaudeCodeResult, String>
|
) -> Result<ClaudeCodeResult, String>
|
||||||
where
|
where
|
||||||
F: FnMut(&str) + Send,
|
F: FnMut(&str) + Send,
|
||||||
@@ -71,6 +84,7 @@ impl ClaudeCodeProvider {
|
|||||||
token_tx,
|
token_tx,
|
||||||
msg_tx,
|
msg_tx,
|
||||||
sid_tx,
|
sid_tx,
|
||||||
|
permission_tx,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,6 +114,7 @@ impl ClaudeCodeProvider {
|
|||||||
/// Sends streaming text tokens via `token_tx` for real-time display, and
|
/// Sends streaming text tokens via `token_tx` for real-time display, and
|
||||||
/// complete structured `Message` values via `msg_tx` for the final message
|
/// complete structured `Message` values via `msg_tx` for the final message
|
||||||
/// history (assistant turns with tool_calls, and tool result turns).
|
/// history (assistant turns with tool_calls, and tool result turns).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_pty_session(
|
fn run_pty_session(
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
cwd: &str,
|
cwd: &str,
|
||||||
@@ -108,6 +123,7 @@ fn run_pty_session(
|
|||||||
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||||
msg_tx: std::sync::mpsc::Sender<Message>,
|
msg_tx: std::sync::mpsc::Sender<Message>,
|
||||||
sid_tx: tokio::sync::oneshot::Sender<String>,
|
sid_tx: tokio::sync::oneshot::Sender<String>,
|
||||||
|
permission_tx: Option<tokio::sync::mpsc::UnboundedSender<PermissionReqMsg>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
@@ -160,8 +176,11 @@ fn run_pty_session(
|
|||||||
.try_clone_reader()
|
.try_clone_reader()
|
||||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||||
|
|
||||||
// We don't need to write anything — -p mode takes prompt as arg
|
// Keep a writer handle so we can respond to permission_request events.
|
||||||
drop(pair.master);
|
let mut pty_writer = pair
|
||||||
|
.master
|
||||||
|
.take_writer()
|
||||||
|
.map_err(|e| format!("Failed to take PTY writer: {e}"))?;
|
||||||
|
|
||||||
// Read NDJSON lines from stdout
|
// Read NDJSON lines from stdout
|
||||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
|
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
|
||||||
@@ -256,6 +275,53 @@ fn run_pty_session(
|
|||||||
"system" => {}
|
"system" => {}
|
||||||
// Rate limit info — suppress noisy notification
|
// Rate limit info — suppress noisy notification
|
||||||
"rate_limit_event" => {}
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user