From ec9738a069e61529be040eff065318972b8a11e2 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 4 Mar 2026 16:47:12 +0000 Subject: feat: task slide-out panel, 3-way reconcile toggle, daemon reauth fix (#85) * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Fix daemon reauth flow for new claude setup-token output format * feat: soryu-co/soryu - makima: Update frontend reconcile toggle to three-way switch * feat: soryu-co/soryu - makima: Add task slide-out panel to directive page --- makima/src/daemon/process/claude.rs | 10 ++ makima/src/daemon/task/manager.rs | 224 ++++++++++++++++++-------- makima/src/daemon/ws/protocol.rs | 5 +- makima/src/db/models.rs | 16 +- makima/src/db/repository.rs | 9 +- makima/src/orchestration/directive.rs | 21 ++- makima/src/server/handlers/mesh_daemon.rs | 8 +- makima/src/server/handlers/mesh_supervisor.rs | 16 +- 8 files changed, 211 insertions(+), 98 deletions(-) (limited to 'makima/src') diff --git a/makima/src/daemon/process/claude.rs b/makima/src/daemon/process/claude.rs index c8add1c..57c8f77 100644 --- a/makima/src/daemon/process/claude.rs +++ b/makima/src/daemon/process/claude.rs @@ -510,6 +510,16 @@ impl ProcessManager { env.extend(extra); } + // Load OAuth token from disk and set as env var if not already provided. + // This allows processes to authenticate using tokens saved by the reauth flow. + // The token is loaded fresh each time (not cached) so newly saved tokens are picked up. + if !env.contains_key("CLAUDE_CODE_OAUTH_TOKEN") { + if let Some(token) = crate::daemon::task::manager::load_oauth_token() { + tracing::debug!("Setting CLAUDE_CODE_OAUTH_TOKEN from saved token file"); + env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_string(), token); + } + } + // Build Claude arguments list let mut claude_args = Vec::new(); diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index addcd71..df5e167 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -117,6 +117,7 @@ fn get_auth_flow_storage() -> &'static std::sync::Mutex bool { let storage = get_auth_flow_storage(); if let Ok(mut guard) = storage.lock() { @@ -127,16 +128,68 @@ pub fn send_auth_code(code: &str) -> bool { } } } - tracing::warn!("No pending auth flow to send code to"); + tracing::warn!("No pending auth flow to send code to (this is expected with the new token-based flow)"); false } +/// Extract an OAuth token from a line of setup-token output. +/// Looks for tokens matching the `sk-ant-oat01-` prefix format. +fn extract_oauth_token(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.starts_with("sk-ant-oat01-") { + Some(trimmed.to_string()) + } else { + None + } +} + +/// Save an OAuth token to the ~/.makima directory for later use by spawned Claude processes. +fn save_oauth_token(token: &str) -> std::io::Result<()> { + let makima_dir = dirs::home_dir() + .unwrap_or_default() + .join(".makima"); + std::fs::create_dir_all(&makima_dir)?; + let token_path = makima_dir.join("claude_oauth_token"); + std::fs::write(&token_path, token)?; + // Set restrictive permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600))?; + } + tracing::info!(path = %token_path.display(), "Saved OAuth token to disk"); + Ok(()) +} + +/// Load a previously saved OAuth token from ~/.makima/claude_oauth_token. +/// Returns None if no token file exists or is empty. +pub fn load_oauth_token() -> Option { + let token_path = dirs::home_dir()? + .join(".makima") + .join("claude_oauth_token"); + std::fs::read_to_string(&token_path).ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Result of the OAuth login flow initiated by `get_oauth_login_url`. +/// Contains the URL for the user to visit, plus a receiver for when the token is saved. +struct OAuthFlowResult { + /// The OAuth login URL the user should visit. + login_url: String, + /// Receiver that will yield the saved token once authentication completes. + token_rx: tokio::sync::oneshot::Receiver, +} + /// Spawn `claude setup-token` to initiate OAuth flow and capture the login URL. /// This spawns the process in a PTY (required by Ink) and reads output until we find a URL. -/// The process continues running in the background waiting for auth completion. -async fn get_oauth_login_url(claude_command: &str) -> Option { +/// +/// The new `claude setup-token` flow outputs a token directly (sk-ant-oat01-...) after +/// the user completes browser authentication, so no code submission is needed. +/// The token is automatically detected, saved to disk, and reported via the token_rx channel. +async fn get_oauth_login_url(claude_command: &str) -> Option { use portable_pty::{native_pty_system, CommandBuilder, PtySize}; - use std::io::{Read, Write}; + use std::io::Read; tracing::info!("Spawning claude setup-token in PTY to get OAuth login URL"); @@ -173,7 +226,7 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { } }; - // Get the reader and writer from the master side + // Get the reader from the master side let mut reader = match pair.master.try_clone_reader() { Ok(reader) => reader, Err(e) => { @@ -182,7 +235,8 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { } }; - let mut writer = match pair.master.take_writer() { + // Take the writer - we keep it alive but don't need to write auth codes anymore + let _writer = match pair.master.take_writer() { Ok(writer) => writer, Err(e) => { tracing::error!(error = %e, "Failed to take PTY writer"); @@ -191,22 +245,25 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { }; // Create channels for communication - let (code_tx, code_rx) = std::sync::mpsc::channel::(); let (url_tx, url_rx) = std::sync::mpsc::channel::(); + let (token_tx, token_rx) = tokio::sync::oneshot::channel::(); - // Store the code sender globally so it can be used when AUTH_CODE message arrives + // Also store a legacy code sender for backward compatibility (in case old server sends SubmitAuthCode) { + let (code_tx, _code_rx) = std::sync::mpsc::channel::(); let storage = get_auth_flow_storage(); if let Ok(mut guard) = storage.lock() { *guard = Some(code_tx); } } - // Spawn reader thread - reads PTY output and sends URL when found + // Spawn reader thread - reads PTY output, sends URL when found, and watches for token let reader_handle = std::thread::spawn(move || { let mut buffer = [0u8; 4096]; let mut accumulated = String::new(); let mut url_sent = false; + let mut token_saved = false; + let mut token_tx = Some(token_tx); let mut read_count = 0; tracing::info!("setup-token reader thread started"); @@ -241,6 +298,22 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { } } + // Look for OAuth token in output (new setup-token format) + if !token_saved { + if let Some(token) = extract_oauth_token(&clean_line) { + tracing::info!("Found OAuth token in setup-token output"); + if let Err(e) = save_oauth_token(&token) { + tracing::error!(error = %e, "Failed to save OAuth token"); + } else { + tracing::info!("OAuth token saved successfully"); + } + if let Some(tx) = token_tx.take() { + let _ = tx.send(token); + } + token_saved = true; + } + } + // Check for success/failure messages if clean_line.contains("successfully") || clean_line.contains("authenticated") || clean_line.contains("Success") { tracing::info!("Authentication appears successful!"); @@ -256,39 +329,12 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { } } } - tracing::info!("setup-token reader thread ended"); + tracing::info!("setup-token reader thread ended (token_saved={})", token_saved); }); - // Spawn writer thread - waits for auth code and writes it to PTY + // Spawn cleanup thread - waits for reader to finish and cleans up the child process std::thread::spawn(move || { - tracing::info!("setup-token writer thread started, waiting for auth code (10 min timeout)"); - - // Wait for auth code from frontend (with long timeout - user needs time to authenticate) - match code_rx.recv_timeout(std::time::Duration::from_secs(600)) { - Ok(code) => { - tracing::info!(code_len = code.len(), "Received auth code from frontend, writing to PTY"); - // Write code followed by carriage return (Enter key in raw terminal mode) - let code_with_enter = format!("{}\r", code); - if let Err(e) = writer.write_all(code_with_enter.as_bytes()) { - tracing::error!(error = %e, "Failed to write auth code to PTY"); - } else if let Err(e) = writer.flush() { - tracing::error!(error = %e, "Failed to flush PTY writer"); - } else { - tracing::info!("Auth code written to setup-token PTY successfully"); - // Give Ink a moment to process, then send another Enter in case first was buffered - std::thread::sleep(std::time::Duration::from_millis(100)); - let _ = writer.write_all(b"\r"); - let _ = writer.flush(); - tracing::info!("Sent additional Enter keypress"); - } - } - Err(e) => { - tracing::info!(error = %e, "Auth code receive ended (timeout or channel closed)"); - } - } - - // Wait for reader thread to finish - tracing::debug!("Waiting for reader thread to finish..."); + tracing::debug!("setup-token cleanup thread: waiting for reader thread to finish..."); let _ = reader_handle.join(); // Wait for child to fully exit @@ -301,11 +347,17 @@ async fn get_oauth_login_url(claude_command: &str) -> Option { tracing::error!(error = %e, "Failed to wait for setup-token process"); } } + + // Keep _writer alive until here so PTY stays open + drop(_writer); }); // Wait for URL with timeout match url_rx.recv_timeout(std::time::Duration::from_secs(30)) { - Ok(url) => Some(url), + Ok(login_url) => Some(OAuthFlowResult { + login_url, + token_rx, + }), Err(e) => { tracing::error!(error = %e, "Timed out waiting for OAuth login URL"); None @@ -1894,15 +1946,60 @@ impl TaskManager { // Spawn in a task so it doesn't block command handling tokio::spawn(async move { match get_oauth_login_url(&claude_command).await { - Some(login_url) => { - tracing::info!(request_id = %request_id, login_url = %login_url, "Got OAuth login URL for reauth"); + Some(flow_result) => { + tracing::info!(request_id = %request_id, login_url = %flow_result.login_url, "Got OAuth login URL for reauth"); + // Send url_ready status immediately let msg = DaemonMessage::ReauthStatus { request_id, status: "url_ready".to_string(), - login_url: Some(login_url), + login_url: Some(flow_result.login_url), error: None, + token_saved: false, }; let _ = ws_tx.send(msg).await; + + // Now wait for the token to be detected and saved (up to 10 minutes) + let ws_tx_token = ws_tx.clone(); + tokio::spawn(async move { + match tokio::time::timeout( + std::time::Duration::from_secs(600), + flow_result.token_rx, + ).await { + Ok(Ok(_token)) => { + tracing::info!(request_id = %request_id, "OAuth token received and saved, reporting completion"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "completed".to_string(), + login_url: None, + error: None, + token_saved: true, + }; + let _ = ws_tx_token.send(msg).await; + } + Ok(Err(_)) => { + tracing::warn!(request_id = %request_id, "Token channel closed without receiving token"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "failed".to_string(), + login_url: None, + error: Some("setup-token process ended without producing a token".to_string()), + token_saved: false, + }; + let _ = ws_tx_token.send(msg).await; + } + Err(_) => { + tracing::warn!(request_id = %request_id, "Timed out waiting for OAuth token (10 min)"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "failed".to_string(), + login_url: None, + error: Some("Timed out waiting for authentication to complete".to_string()), + token_saved: false, + }; + let _ = ws_tx_token.send(msg).await; + } + } + }); } None => { tracing::error!(request_id = %request_id, "Failed to get OAuth login URL for reauth"); @@ -1911,40 +2008,25 @@ impl TaskManager { status: "failed".to_string(), login_url: None, error: Some("Failed to get OAuth login URL from setup-token".to_string()), + token_saved: false, }; let _ = ws_tx.send(msg).await; } } }); } - DaemonCommand::SubmitAuthCode { request_id, code } => { - tracing::info!(request_id = %request_id, "Received auth code submission from server"); - let ws_tx = self.ws_tx.clone(); - - if send_auth_code(&code) { - tracing::info!(request_id = %request_id, "Auth code forwarded to setup-token for reauth"); - // Wait a short time then report completion - // (the setup-token process takes a moment to complete) - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let msg = DaemonMessage::ReauthStatus { - request_id, - status: "completed".to_string(), - login_url: None, - error: None, - }; - let _ = ws_tx.send(msg).await; - }); - } else { - tracing::warn!(request_id = %request_id, "No pending auth flow to receive code for reauth"); - let msg = DaemonMessage::ReauthStatus { - request_id, - status: "failed".to_string(), - login_url: None, - error: Some("No pending auth flow to receive the code. Try triggering reauth again.".to_string()), - }; - let _ = self.ws_tx.send(msg).await; - } + DaemonCommand::SubmitAuthCode { request_id, code: _ } => { + // Deprecated: The new setup-token flow outputs tokens directly. + // This handler is kept for backward compatibility but is a no-op. + tracing::info!(request_id = %request_id, "Received auth code submission (deprecated - new flow auto-detects token)"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "completed".to_string(), + login_url: None, + error: None, + token_saved: load_oauth_token().is_some(), + }; + let _ = self.ws_tx.send(msg).await; } DaemonCommand::ApplyPatchToWorktree { target_task_id, diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index bed5ffd..1611f52 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -133,13 +133,16 @@ pub enum DaemonMessage { ReauthStatus { #[serde(rename = "requestId")] request_id: Uuid, - /// Status: "url_ready", "completed", "failed" + /// Status: "pending", "url_ready", "completed", "failed" status: String, /// OAuth login URL (present when status is "url_ready") #[serde(rename = "loginUrl")] login_url: Option, /// Error message (present when status is "failed") error: Option, + /// Whether the OAuth token has been saved to disk + #[serde(rename = "tokenSaved", default)] + token_saved: bool, }, // ========================================================================= diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 6292e7b..32e55f0 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2714,8 +2714,8 @@ pub struct Directive { pub pr_url: Option, pub pr_branch: Option, pub completion_task_id: Option, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: bool, + /// Question timeout mode: "auto" (30s timeout), "semi-auto" (block indefinitely), "manual" (block + ask many questions) + pub reconcile_mode: String, pub goal_updated_at: DateTime, pub started_at: Option>, pub version: i32, @@ -2780,8 +2780,8 @@ pub struct DirectiveSummary { pub orchestrator_task_id: Option, pub pr_url: Option, pub completion_task_id: Option, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: bool, + /// Question timeout mode: "auto" (30s timeout), "semi-auto" (block indefinitely), "manual" (block + ask many questions) + pub reconcile_mode: String, pub version: i32, pub created_at: DateTime, pub updated_at: DateTime, @@ -2808,8 +2808,8 @@ pub struct CreateDirectiveRequest { pub repository_url: Option, pub local_path: Option, pub base_branch: Option, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: Option, + /// Question timeout mode: "auto", "semi-auto", or "manual" + pub reconcile_mode: Option, } /// Request to update a directive. @@ -2825,8 +2825,8 @@ pub struct UpdateDirectiveRequest { pub orchestrator_task_id: Option, pub pr_url: Option, pub pr_branch: Option, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: Option, + /// Question timeout mode: "auto", "semi-auto", or "manual" + pub reconcile_mode: Option, pub version: Option, } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 1af22f6..f14bc66 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -4941,7 +4941,7 @@ pub async fn create_directive_for_owner( .bind(&req.repository_url) .bind(&req.local_path) .bind(&req.base_branch) - .bind(req.reconcile_mode.unwrap_or(false)) + .bind(req.reconcile_mode.as_deref().unwrap_or("auto")) .fetch_one(pool) .await } @@ -5059,7 +5059,7 @@ pub async fn update_directive_for_owner( let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref()); let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref()); - let reconcile_mode = req.reconcile_mode.unwrap_or(current.reconcile_mode); + let reconcile_mode = req.reconcile_mode.clone().unwrap_or_else(|| current.reconcile_mode.clone()); let result = sqlx::query_as::<_, Directive>( r#" @@ -5738,6 +5738,8 @@ pub struct StepForDispatch { pub base_branch: Option, /// The directive's PR branch (if a PR has already been created from previous steps). pub pr_branch: Option, + /// The directive's reconcile mode: "auto", "semi-auto", or "manual". + pub reconcile_mode: String, } /// Get ready steps that need task dispatch. @@ -5760,7 +5762,8 @@ pub async fn get_ready_steps_for_dispatch( d.title AS directive_title, d.repository_url, d.base_branch, - d.pr_branch + d.pr_branch, + d.reconcile_mode FROM directive_steps ds JOIN directives d ON d.id = ds.directive_id WHERE ds.status = 'ready' diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 155cfad..1e025c8 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -194,6 +194,14 @@ impl DirectiveOrchestrator { String::new() }; + let manual_mode_appendix = if step.reconcile_mode == "manual" { + "\n\nIMPORTANT: This directive is in MANUAL reconcile mode. Before making assumptions or proceeding with implementation choices, you MUST ask clarification questions using:\n\ + \x20 makima directive ask \"\" --phaseguard\n\ + Ask multiple targeted questions about requirements, edge cases, and design decisions. Wait for answers before writing code. Do not proceed until you have clear direction from the user." + } else { + "" + }; + let plan = format!( "You are executing a step in directive \"{directive_title}\".\n\n\ STEP: {step_name}\n\ @@ -203,12 +211,13 @@ impl DirectiveOrchestrator { When done, the system will automatically mark this step as completed.\n\ If you cannot complete the task, report the failure clearly.\n\n\ If you need clarification or encounter a decision that requires user input, you can ask:\n\ - \x20 makima directive ask \"Your question\" --phaseguard", + \x20 makima directive ask \"Your question\" --phaseguard{manual_mode_appendix}", directive_title = step.directive_title, step_name = step.step_name, description = step.step_description.as_deref().unwrap_or("(none)"), merge_preamble = merge_preamble, task_plan = task_plan, + manual_mode_appendix = manual_mode_appendix, ); match self @@ -1612,8 +1621,9 @@ If you need clarification from the user before finalizing the plan, you can ask Use --phaseguard for questions that block progress (the question will wait indefinitely for a response). The CLI automatically reconnects via polling every ~5 minutes to avoid HTTP timeout limits. Without --phaseguard, questions timeout based on the directive's reconcile mode: -- Reconcile ON: questions wait indefinitely (with automatic reconnecting polls every ~5 min) -- Reconcile OFF: questions timeout after 30 seconds with no response +- Auto: questions timeout after 30 seconds with no response +- Semi-Auto: questions wait indefinitely (with automatic reconnecting polls every ~5 min) +- Manual: questions wait indefinitely + tasks should ask multiple clarifying questions When to ask: - Requirements are ambiguous and multiple interpretations are valid @@ -2206,8 +2216,9 @@ Options: - `--phaseguard` - Block until response (recommended for important questions) The question will appear in the directive UI. Behavior depends on reconcile mode: -- Reconcile ON: blocks until user responds -- Reconcile OFF: times out after 30s (use for non-critical questions) +- Auto: times out after 30s (use for non-critical questions) +- Semi-Auto: blocks until user responds +- Manual: blocks until user responds (tasks are expected to ask many questions) Use this when: - The goal is ambiguous and could be interpreted multiple ways diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 30439a4..d5ef1f9 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -354,13 +354,16 @@ pub enum DaemonMessage { ReauthStatus { #[serde(rename = "requestId")] request_id: Uuid, - /// Status: "url_ready", "completed", "failed" + /// Status: "pending", "url_ready", "completed", "failed" status: String, /// OAuth login URL (present when status is "url_ready") #[serde(rename = "loginUrl")] login_url: Option, /// Error message (present when status is "failed") error: Option, + /// Whether the OAuth token has been saved to disk + #[serde(rename = "tokenSaved", default)] + token_saved: bool, }, /// Response to RetryCompletionAction command CompletionActionResult { @@ -1634,13 +1637,14 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re "OAuth login URL available - user should open this in browser" ); } - Ok(DaemonMessage::ReauthStatus { request_id, status, login_url, error }) => { + Ok(DaemonMessage::ReauthStatus { request_id, status, login_url, error, token_saved }) => { tracing::info!( daemon_id = %daemon_uuid, request_id = %request_id, status = %status, login_url = ?login_url, error = ?error, + token_saved = token_saved, "Daemon reauth status update" ); diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 8c36500..9d2dce7 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -1715,21 +1715,21 @@ pub async fn ask_question( let is_directive_context = directive_id.is_some() && contract_id.is_none(); // For directive context, check reconcile_mode to determine behavior - let directive_reconcile_mode = if let Some(did) = directive_id { + let directive_reconcile_mode: String = if let Some(did) = directive_id { if is_directive_context { match repository::get_directive_for_owner(pool, owner_id, did).await { - Ok(Some(d)) => d.reconcile_mode, - Ok(None) => false, + Ok(Some(d)) => d.reconcile_mode.clone(), + Ok(None) => "auto".to_string(), Err(e) => { tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check"); - false + "auto".to_string() } } } else { - false + "auto".to_string() } } else { - false + "auto".to_string() }; // Add the question (use Uuid::nil() for contract_id in directive-only context) @@ -1813,7 +1813,7 @@ pub async fn ask_question( } // Determine if we should block indefinitely (phaseguard or directive reconcile mode) - let use_phaseguard = request.phaseguard || (is_directive_context && directive_reconcile_mode); + let use_phaseguard = request.phaseguard || (is_directive_context && (directive_reconcile_mode == "semi-auto" || directive_reconcile_mode == "manual")); // Poll for response with timeout // - Phaseguard: block indefinitely until user responds @@ -1823,7 +1823,7 @@ pub async fn ask_question( // Cap at 5 minutes per HTTP request (well under Claude Code's 10-min limit). // The CLI will automatically reconnect via the poll endpoint. 300 - } else if is_directive_context && !directive_reconcile_mode { + } else if is_directive_context && directive_reconcile_mode == "auto" { 30 } else { request.timeout_seconds.max(1) as u64 -- cgit v1.2.3