summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/directives/DirectiveDAG.tsx19
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx61
-rw-r--r--makima/frontend/src/components/directives/StepNode.tsx12
-rw-r--r--makima/frontend/src/components/directives/TaskSlideOutPanel.tsx179
-rw-r--r--makima/frontend/src/lib/api.ts16
-rw-r--r--makima/frontend/src/routes/daemons.tsx261
-rw-r--r--makima/migrations/20260304000000_reconcile_mode_to_text.sql4
-rw-r--r--makima/src/daemon/process/claude.rs10
-rw-r--r--makima/src/daemon/task/manager.rs224
-rw-r--r--makima/src/daemon/ws/protocol.rs5
-rw-r--r--makima/src/db/models.rs16
-rw-r--r--makima/src/db/repository.rs9
-rw-r--r--makima/src/orchestration/directive.rs21
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs8
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs16
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