story-kit: merge 91_bug_permissions_dialog_never_triggers_in_web_ui

This commit is contained in:
Dave
2026-02-23 21:38:45 +00:00
parent 02a1edc3de
commit 3087297b88
6 changed files with 127 additions and 95 deletions

View File

@@ -1,24 +1,12 @@
use crate::slog;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader};
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
@@ -37,6 +25,10 @@ pub struct ClaudeCodeResult {
/// Supports session resumption: if a `session_id` is provided, passes
/// `--resume <id>` so Claude Code loads the prior conversation transcript
/// from disk and continues with full context.
///
/// Permissions are delegated to the MCP `prompt_permission` tool via
/// `--permission-prompt-tool`, so Claude Code calls back into the server
/// when a tool requires user approval. The frontend dialog handles the UX.
pub struct ClaudeCodeProvider;
impl ClaudeCodeProvider {
@@ -51,7 +43,6 @@ 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,
@@ -85,7 +76,6 @@ impl ClaudeCodeProvider {
token_tx,
msg_tx,
sid_tx,
permission_tx,
)
});
@@ -115,7 +105,10 @@ 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)]
///
/// Permission handling is delegated to the MCP `prompt_permission` tool
/// via `--permission-prompt-tool`. Claude Code calls the MCP tool when it
/// needs user approval, and the server bridges the request to the frontend.
fn run_pty_session(
user_message: &str,
cwd: &str,
@@ -124,7 +117,6 @@ 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();
@@ -147,6 +139,11 @@ fn run_pty_session(
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
// Delegate permission decisions to the MCP prompt_permission tool.
// Claude Code will call this tool via the story-kit MCP server when
// a tool requires user approval, instead of using PTY stdin/stdout.
cmd.arg("--permission-prompt-tool");
cmd.arg("mcp__story-kit__prompt_permission");
cmd.cwd(cwd);
// Keep TERM reasonable but disable color
cmd.env("NO_COLOR", "1");
@@ -154,7 +151,7 @@ fn run_pty_session(
cmd.env("CLAUDECODE", "");
slog!(
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose",
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --permission-prompt-tool mcp__story-kit__prompt_permission",
user_message,
resume_session_id
.map(|s| format!("--resume {s}"))
@@ -177,11 +174,9 @@ fn run_pty_session(
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
// 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}"))?;
// We no longer need the writer — permission responses flow through MCP,
// not PTY stdin. Drop it so the PTY sees EOF on stdin when appropriate.
drop(pair.master);
// Read NDJSON lines from stdout
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
@@ -276,53 +271,6 @@ 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);
}
}
_ => {}
}
}