diff options
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDAG.tsx | 19 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 61 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/StepNode.tsx | 12 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/TaskSlideOutPanel.tsx | 179 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 16 | ||||
| -rw-r--r-- | makima/frontend/src/routes/daemons.tsx | 261 | ||||
| -rw-r--r-- | makima/migrations/20260304000000_reconcile_mode_to_text.sql | 4 | ||||
| -rw-r--r-- | makima/src/daemon/process/claude.rs | 10 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 224 | ||||
| -rw-r--r-- | makima/src/daemon/ws/protocol.rs | 5 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 16 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 9 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 21 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 16 |
15 files changed, 562 insertions, 299 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx index f225356..7fa2ccf 100644 --- a/makima/frontend/src/components/directives/DirectiveDAG.tsx +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -28,6 +28,7 @@ interface DirectiveDAGProps { onComplete?: (stepId: string) => void; onFail?: (stepId: string) => void; onSkip?: (stepId: string) => void; + onViewTask?: (taskId: string) => void; } interface Layer { @@ -52,7 +53,7 @@ function topoSort(steps: DirectiveStep[]): Layer[] { })); } -export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSkip }: DirectiveDAGProps) { +export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSkip, onViewTask }: DirectiveDAGProps) { const layers = useMemo(() => topoSort(steps), [steps]); const orchestratorSteps = specializedSteps?.filter(s => s.type === "orchestrator") ?? []; @@ -70,7 +71,7 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk <div className="flex flex-col gap-4 items-center py-4"> {/* Orchestrator steps (Planning/Cleanup/Orders) - rendered above regular steps */} {orchestratorSteps.map(step => ( - <SpecializedStepNode key={step.id} step={step} /> + <SpecializedStepNode key={step.id} step={step} onViewTask={onViewTask} /> ))} {/* Connector line if both orchestrator step and regular steps exist */} @@ -96,6 +97,7 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk onComplete={onComplete ? () => onComplete(step.id) : undefined} onFail={onFail ? () => onFail(step.id) : undefined} onSkip={onSkip ? () => onSkip(step.id) : undefined} + onViewTask={onViewTask} /> ))} </div> @@ -111,13 +113,13 @@ export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSk {/* Completion steps (PR creation) - rendered below regular steps */} {completionSteps.map(step => ( - <SpecializedStepNode key={step.id} step={step} /> + <SpecializedStepNode key={step.id} step={step} onViewTask={onViewTask} /> ))} </div> ); } -function SpecializedStepNode({ step }: { step: SpecializedStep }) { +function SpecializedStepNode({ step, onViewTask }: { step: SpecializedStep; onViewTask?: (taskId: string) => void }) { const themeColors = step.type === "orchestrator" ? { bg: "bg-[#1a1a30]", @@ -145,12 +147,13 @@ function SpecializedStepNode({ step }: { step: SpecializedStep }) { <span className={`text-[11px] font-mono ${themeColors.text} flex-1 truncate`}> {step.name} </span> - <a - href={`/exec/${step.taskId}`} - className="text-[9px] font-mono text-[#556677] hover:text-white underline" + <button + type="button" + onClick={() => onViewTask?.(step.taskId)} + className="text-[9px] font-mono text-[#556677] hover:text-white underline bg-transparent border-none p-0 cursor-pointer" > View task - </a> + </button> </div> ); } diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 8f39207..5f3489a 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -3,6 +3,7 @@ import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from import { DirectiveDAG } from "./DirectiveDAG"; import type { SpecializedStep } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; +import { TaskSlideOutPanel } from "./TaskSlideOutPanel"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; @@ -53,6 +54,11 @@ export function DirectiveDetail({ const [pickingUpOrders, setPickingUpOrders] = useState(false); const [pickUpResult, setPickUpResult] = useState<string | null>(null); const [creatingPR, setCreatingPR] = useState(false); + const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null); + + const handleViewTask = (taskId: string) => { + setSlideOutTaskId(taskId); + }; // Sync goalText and reset editing state when directive changes useEffect(() => { @@ -178,7 +184,15 @@ export function DirectiveDetail({ }; + // Find the task name for the slide-out panel + const slideOutTaskName = slideOutTaskId + ? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ?? + taskMap.get(slideOutTaskId) ?? + undefined) + : undefined; + return ( + <> <div className="flex flex-col h-full overflow-y-auto"> {/* Header */} <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> @@ -228,21 +242,31 @@ export function DirectiveDetail({ {/* Reconcile mode toggle */} <div className="flex items-center gap-2 mb-2"> - <button - type="button" - onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })} - className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ - directive.reconcileMode - ? "text-amber-400 border-amber-800 bg-amber-900/20" - : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" - }`} - > - {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"} - </button> + <div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden"> + {(["auto", "semi-auto", "manual"] as const).map((mode) => { + const isActive = directive.reconcileMode === mode; + const modeStyles: Record<string, string> = { + auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]", + "semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]", + manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]", + }; + const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" }; + return ( + <button + key={mode} + type="button" + onClick={() => onUpdate({ reconcileMode: mode })} + className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`} + > + {labels[mode]} + </button> + ); + })} + </div> <span className="text-[9px] font-mono text-[#445566]"> - {directive.reconcileMode - ? "Questions pause execution" - : "Questions timeout after 30s"} + {directive.reconcileMode === "auto" && "Questions timeout after 30s"} + {directive.reconcileMode === "semi-auto" && "Questions pause execution"} + {directive.reconcileMode === "manual" && "Tasks ask clarifying questions"} </span> </div> @@ -424,6 +448,7 @@ export function DirectiveDetail({ onComplete={onCompleteStep} onFail={onFailStep} onSkip={onSkipStep} + onViewTask={handleViewTask} /> </div> @@ -445,6 +470,14 @@ export function DirectiveDetail({ </div> )} </div> + + <TaskSlideOutPanel + taskId={slideOutTaskId || ""} + taskName={slideOutTaskName} + isOpen={!!slideOutTaskId} + onClose={() => setSlideOutTaskId(null)} + /> + </> ); } diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx index 775b898..f854297 100644 --- a/makima/frontend/src/components/directives/StepNode.tsx +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -23,9 +23,10 @@ interface StepNodeProps { onComplete?: () => void; onFail?: () => void; onSkip?: () => void; + onViewTask?: (taskId: string) => void; } -export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { +export function StepNode({ step, onComplete, onFail, onSkip, onViewTask }: StepNodeProps) { const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); const isContractBacked = !!step.contractType; @@ -66,12 +67,13 @@ export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { </a> )} {step.taskId && !step.contractId && ( - <a - href={`/exec/${step.taskId}`} - className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1" + <button + type="button" + onClick={() => onViewTask?.(step.taskId!)} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1 bg-transparent border-none p-0 cursor-pointer text-left" > {step.status === "running" ? "Auto-executing..." : "View task"} - </a> + </button> )} {(step.status === "running" || step.status === "ready") && ( <div className="flex gap-1 mt-1"> diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx new file mode 100644 index 0000000..29fce23 --- /dev/null +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTaskSubscription } from "../../hooks/useTaskSubscription"; +import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; +import { TaskOutput } from "../mesh/TaskOutput"; +import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; +import { getTaskOutput } from "../../lib/api"; + +interface TaskSlideOutPanelProps { + taskId: string; + taskName?: string; + isOpen: boolean; + onClose: () => void; +} + +export function TaskSlideOutPanel({ + taskId, + taskName, + isOpen, + onClose, +}: TaskSlideOutPanelProps) { + const [entries, setEntries] = useState<TaskOutputEvent[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Escape key handler + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose]); + + // Load historical output when panel opens with a taskId + useEffect(() => { + if (!isOpen || !taskId) { + setEntries([]); + setIsStreaming(false); + return; + } + + let cancelled = false; + setLoadingHistory(true); + + getTaskOutput(taskId) + .then((res) => { + if (cancelled) return; + // Map TaskOutputEntry to TaskOutputEvent + const mapped: TaskOutputEvent[] = res.entries.map((e) => ({ + taskId: e.taskId, + messageType: e.messageType, + content: e.content, + toolName: e.toolName, + toolInput: e.toolInput, + isError: e.isError, + costUsd: e.costUsd, + durationMs: e.durationMs, + isPartial: false, + })); + setEntries(mapped); + }) + .catch((err) => { + if (cancelled) return; + console.error("Failed to load task output history:", err); + }) + .finally(() => { + if (!cancelled) setLoadingHistory(false); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, taskId]); + + // Handle live output events + const handleOutput = useCallback( + (event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => [...prev, event]); + setIsStreaming(true); + }, + [] + ); + + // Handle task updates (to detect completion) + const handleUpdate = useCallback( + (event: { status: string }) => { + if ( + event.status === "completed" || + event.status === "failed" || + event.status === "cancelled" + ) { + setIsStreaming(false); + } else if (event.status === "running") { + setIsStreaming(true); + } + }, + [] + ); + + // Subscribe to live output + useTaskSubscription({ + taskId: isOpen ? taskId : null, + subscribeOutput: isOpen && !!taskId, + onOutput: handleOutput, + onUpdate: handleUpdate, + }); + + return ( + <> + {/* Backdrop overlay */} + <div + className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${ + isOpen ? "opacity-100" : "opacity-0 pointer-events-none" + }`} + onClick={onClose} + /> + + {/* Slide-out panel */} + <div + className={`fixed top-0 right-0 h-full w-[550px] max-w-[90vw] z-50 bg-[#0d1117] border-l border-[rgba(117,170,252,0.2)] shadow-xl shadow-black/50 flex flex-col transition-transform duration-300 ease-in-out ${ + isOpen ? "translate-x-0" : "translate-x-full" + }`} + > + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0"> + <div className="flex items-center gap-2 min-w-0 flex-1"> + <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0"> + Task + </span> + <span className="text-[12px] font-mono text-white truncate"> + {taskName || taskId} + </span> + {isStreaming && ( + <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + <span className="text-green-400 font-mono text-[9px] uppercase"> + Live + </span> + </span> + )} + </div> + <button + type="button" + onClick={onClose} + className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center" + > + ✕ + </button> + </div> + + {/* Content */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> + {/* Task Output section (~60% height) */} + <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> + {loadingHistory ? ( + <div className="flex-1 flex items-center justify-center"> + <span className="font-mono text-xs text-[#555] animate-pulse"> + Loading output... + </span> + </div> + ) : ( + <TaskOutput + entries={entries} + isStreaming={isStreaming} + taskId={taskId} + /> + )} + </div> + + {/* Worktree Changes section (~40% height) */} + <div className="flex-[2] min-h-0 overflow-y-auto"> + {taskId && <WorktreeFilesPanel taskId={taskId} />} + </div> + </div> + </div> + </> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 155c716..4923c1d 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3133,8 +3133,8 @@ export interface Directive { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode: string; goalUpdatedAt: string; startedAt: string | null; version: number; @@ -3178,8 +3178,8 @@ export interface DirectiveSummary { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode: string; version: number; createdAt: string; updatedAt: string; @@ -3202,8 +3202,8 @@ export interface CreateDirectiveRequest { baseBranch?: string; /** Enable the memory system for this directive (default: false) */ memoryEnabled?: boolean; - /** Whether questions pause execution indefinitely until answered (default: false) */ - reconcileMode?: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) (default: auto) */ + reconcileMode?: string; } export interface UpdateDirectiveRequest { @@ -3216,8 +3216,8 @@ export interface UpdateDirectiveRequest { orchestratorTaskId?: string; /** Enable or disable the memory system for this directive */ memoryEnabled?: boolean; - /** Whether questions pause execution indefinitely until answered */ - reconcileMode?: boolean; + /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ + reconcileMode?: string; version?: number; } diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx index ca167fe..aa48deb 100644 --- a/makima/frontend/src/routes/daemons.tsx +++ b/makima/frontend/src/routes/daemons.tsx @@ -6,7 +6,6 @@ import { listDaemons, restartDaemon, triggerDaemonReauth, - submitDaemonAuthCode, getDaemonReauthStatus, type Daemon, type DaemonListResponse, @@ -43,7 +42,7 @@ function ErrorAlert({ children }: { children: React.ReactNode }) { type ReauthState = | { phase: "initiating" } | { phase: "url_ready"; loginUrl: string; requestId: string } - | { phase: "submitting"; requestId: string } + | { phase: "waiting_for_auth"; loginUrl: string; requestId: string } | { phase: "success" } | { phase: "error"; message: string }; @@ -55,7 +54,6 @@ function ReauthModal({ onClose: () => void; }) { const [state, setState] = useState<ReauthState>({ phase: "initiating" }); - const [authCode, setAuthCode] = useState(""); const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null); // Cleanup polling on unmount @@ -67,6 +65,53 @@ function ReauthModal({ }; }, []); + // Start polling for status updates (URL ready, then completion) + const startPolling = useCallback( + (requestId: string) => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus(daemon.id, requestId); + + if (status.status === "completed") { + setState({ phase: "success" }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "url_ready" && status.loginUrl) { + setState((prev) => { + // Only update if we haven't already shown the URL + if (prev.phase === "initiating") { + return { + phase: "url_ready", + loginUrl: status.loginUrl!, + requestId, + }; + } + return prev; + }); + // Keep polling for completion - don't stop here + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Polling errors are non-fatal, keep trying + } + }, 2000); + }, + [daemon.id], + ); + // Trigger reauth on mount useEffect(() => { let cancelled = false; @@ -74,45 +119,7 @@ function ReauthModal({ try { const res = await triggerDaemonReauth(daemon.id); if (cancelled) return; - - // Start polling for status - const requestId = res.requestId; - pollingRef.current = setInterval(async () => { - try { - const status = await getDaemonReauthStatus(daemon.id, requestId); - if (cancelled) return; - - if (status.status === "url_ready" && status.loginUrl) { - setState({ - phase: "url_ready", - loginUrl: status.loginUrl, - requestId, - }); - // Stop polling once we have the URL - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } else if (status.status === "failed") { - setState({ - phase: "error", - message: status.error || "Reauth failed", - }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } else if (status.status === "completed") { - setState({ phase: "success" }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } - } catch { - // Polling errors are non-fatal, keep trying - } - }, 2000); + startPolling(res.requestId); } catch (err) { if (cancelled) return; setState({ @@ -126,110 +133,28 @@ function ReauthModal({ return () => { cancelled = true; }; - }, [daemon.id]); - - const handleSubmitCode = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!authCode.trim() || state.phase !== "url_ready") return; - - const requestId = state.requestId; - setState({ phase: "submitting", requestId }); - - try { - await submitDaemonAuthCode(daemon.id, authCode.trim(), requestId); - - // Poll for completion - pollingRef.current = setInterval(async () => { - try { - const status = await getDaemonReauthStatus( - daemon.id, - requestId, - ); - if (status.status === "completed") { - setState({ phase: "success" }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } else if (status.status === "failed") { - setState({ - phase: "error", - message: status.error || "Auth code submission failed", - }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } - } catch { - // Keep polling - } - }, 2000); - - // Also set a timeout so we don't poll forever - setTimeout(() => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - // If still submitting after 30s, assume success (setup-token completed) - setState((prev) => - prev.phase === "submitting" ? { phase: "success" } : prev, - ); - }, 30000); - } catch (err) { - setState({ - phase: "error", - message: - err instanceof Error - ? err.message - : "Failed to submit auth code", - }); + }, [daemon.id, startPolling]); + + // When URL is shown, transition to waiting_for_auth after user clicks the link + const handleOpenedLink = useCallback(() => { + setState((prev) => { + if (prev.phase === "url_ready") { + return { + phase: "waiting_for_auth", + loginUrl: prev.loginUrl, + requestId: prev.requestId, + }; } - }, - [authCode, daemon.id, state], - ); + return prev; + }); + }, []); const handleRetry = useCallback(() => { - setAuthCode(""); setState({ phase: "initiating" }); - // Re-trigger will happen via the useEffect dependency change - // We need to manually trigger since daemon.id hasn't changed const trigger = async () => { try { const res = await triggerDaemonReauth(daemon.id); - const requestId = res.requestId; - pollingRef.current = setInterval(async () => { - try { - const status = await getDaemonReauthStatus( - daemon.id, - requestId, - ); - if (status.status === "url_ready" && status.loginUrl) { - setState({ - phase: "url_ready", - loginUrl: status.loginUrl, - requestId, - }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } else if (status.status === "failed") { - setState({ - phase: "error", - message: status.error || "Reauth failed", - }); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } - } catch { - // Keep polling - } - }, 2000); + startPolling(res.requestId); } catch (err) { setState({ phase: "error", @@ -241,7 +166,7 @@ function ReauthModal({ } }; trigger(); - }, [daemon.id]); + }, [daemon.id, startPolling]); return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> @@ -272,46 +197,50 @@ function ReauthModal({ </div> )} - {/* URL Ready */} + {/* URL Ready - user needs to click the link */} {state.phase === "url_ready" && ( <div className="space-y-3"> <p className="text-[10px] font-mono text-[#7788aa]"> - Click the button below to open the OAuth login page, then paste the code: + Click the button below to open the OAuth login page. Authentication will complete automatically. </p> <a href={state.loginUrl} target="_blank" rel="noopener noreferrer" + onClick={handleOpenedLink} className="block text-center bg-amber-500 hover:bg-amber-400 text-black font-mono text-xs font-medium px-4 py-2 transition-colors" > - 1. Login to Claude + Login to Claude </a> - <form onSubmit={handleSubmitCode} className="flex gap-2"> - <input - type="text" - value={authCode} - onChange={(e) => setAuthCode(e.target.value)} - placeholder="2. Paste authentication code" - className="flex-1 bg-[#0a1525] border border-amber-500/30 px-3 py-2 text-xs font-mono text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" - /> - <button - type="submit" - disabled={!authCode.trim()} - className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-mono text-xs font-medium px-4 py-2 transition-colors" - > - Submit - </button> - </form> + <div className="flex items-center gap-2 pt-1"> + <div className="w-2 h-2 bg-amber-500/50 rounded-full animate-pulse" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Waiting for authentication to complete... + </span> + </div> </div> )} - {/* Submitting */} - {state.phase === "submitting" && ( - <div className="flex items-center gap-2 py-4"> - <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" /> - <span className="text-[10px] font-mono text-[#7788aa]"> - Submitting auth code... - </span> + {/* Waiting for auth - user has clicked the link, waiting for token */} + {state.phase === "waiting_for_auth" && ( + <div className="space-y-3"> + <div className="flex items-center gap-2 py-2"> + <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Waiting for authentication to complete... + </span> + </div> + <p className="text-[10px] font-mono text-[#556677]"> + Complete the login in your browser. The token will be saved automatically. + </p> + <a + href={state.loginUrl} + target="_blank" + rel="noopener noreferrer" + className="inline-block text-[10px] font-mono text-amber-500/70 hover:text-amber-400 underline" + > + Open login page again + </a> </div> )} diff --git a/makima/migrations/20260304000000_reconcile_mode_to_text.sql b/makima/migrations/20260304000000_reconcile_mode_to_text.sql new file mode 100644 index 0000000..a15dd77 --- /dev/null +++ b/makima/migrations/20260304000000_reconcile_mode_to_text.sql @@ -0,0 +1,4 @@ +-- Change reconcile_mode from BOOLEAN to TEXT with three-way enum: auto, semi-auto, manual. +-- Backward compatibility: true -> 'semi-auto', false -> 'auto'. +ALTER TABLE directives ALTER COLUMN reconcile_mode TYPE TEXT USING CASE WHEN reconcile_mode THEN 'semi-auto' ELSE 'auto' END; +ALTER TABLE directives ALTER COLUMN reconcile_mode SET DEFAULT 'auto'; 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<Option<std::sync::mpsc:: } /// Send an auth code to the pending OAuth flow. +/// Deprecated: The new setup-token flow outputs tokens directly, so this is no longer needed. pub fn send_auth_code(code: &str) -> 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<String> { + 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<String> { + 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<String>, +} + /// 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<String> { +/// +/// 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<OAuthFlowResult> { 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<String> { } }; - // 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<String> { } }; - 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<String> { }; // Create channels for communication - let (code_tx, code_rx) = std::sync::mpsc::channel::<String>(); let (url_tx, url_rx) = std::sync::mpsc::channel::<String>(); + let (token_tx, token_rx) = tokio::sync::oneshot::channel::<String>(); - // 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::<String>(); 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<String> { } } + // 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<String> { } } } - 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<String> { 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<String>, /// Error message (present when status is "failed") error: Option<String>, + /// 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<String>, pub pr_branch: Option<String>, pub completion_task_id: Option<Uuid>, - /// 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<Utc>, pub started_at: Option<DateTime<Utc>>, pub version: i32, @@ -2780,8 +2780,8 @@ pub struct DirectiveSummary { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub completion_task_id: Option<Uuid>, - /// 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<Utc>, pub updated_at: DateTime<Utc>, @@ -2808,8 +2808,8 @@ pub struct CreateDirectiveRequest { pub repository_url: Option<String>, pub local_path: Option<String>, pub base_branch: Option<String>, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: Option<bool>, + /// Question timeout mode: "auto", "semi-auto", or "manual" + pub reconcile_mode: Option<String>, } /// Request to update a directive. @@ -2825,8 +2825,8 @@ pub struct UpdateDirectiveRequest { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub pr_branch: Option<String>, - /// Whether questions pause execution indefinitely until answered - pub reconcile_mode: Option<bool>, + /// Question timeout mode: "auto", "semi-auto", or "manual" + pub reconcile_mode: Option<String>, pub version: Option<i32>, } 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<String>, /// The directive's PR branch (if a PR has already been created from previous steps). pub pr_branch: Option<String>, + /// 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 \"<question>\" --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<String>, /// Error message (present when status is "failed") error: Option<String>, + /// 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 |
