summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-20 23:20:32 +0000
committerGitHub <noreply@github.com>2026-01-20 23:20:32 +0000
commit7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc (patch)
tree2475a951c4bcba685b010909bf4abd5351cb3623
parent055e2c4a72e3b2331a18fdc9f8132ef990af7e38 (diff)
downloadsoryu-7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc.tar.gz
soryu-7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc.zip
Add non-blocking persistent contract completion questions (#14)
* [WIP] Heartbeat checkpoint - 2026-01-20 22:40:37 UTC * Task completion checkpoint
-rw-r--r--makima/frontend/src/components/SupervisorQuestionNotification.tsx9
-rw-r--r--makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx165
-rw-r--r--makima/frontend/src/lib/api.ts4
-rw-r--r--makima/frontend/src/routes/mesh.tsx63
-rw-r--r--makima/src/bin/makima.rs2
-rw-r--r--makima/src/daemon/api/supervisor.rs10
-rw-r--r--makima/src/daemon/cli/supervisor.rs8
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs28
-rw-r--r--makima/src/server/state.rs5
9 files changed, 269 insertions, 25 deletions
diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx
index 1457d86..b1cbacc 100644
--- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx
+++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx
@@ -5,7 +5,12 @@ export function SupervisorQuestionNotification() {
const navigate = useNavigate();
const { notificationQuestions, dismissNotification } = useSupervisorQuestions();
- if (notificationQuestions.length === 0) {
+ // Filter out contract_complete questions - they are displayed on the task page instead
+ const filteredQuestions = notificationQuestions.filter(
+ (q) => q.questionType !== "contract_complete"
+ );
+
+ if (filteredQuestions.length === 0) {
return null;
}
@@ -16,7 +21,7 @@ export function SupervisorQuestionNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2">
- {notificationQuestions.map((question) => (
+ {filteredQuestions.map((question) => (
<div
key={question.questionId}
className="bg-[#0d1b2d] border border-amber-500/50 rounded-lg shadow-lg overflow-hidden"
diff --git a/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx b/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx
new file mode 100644
index 0000000..d4ef618
--- /dev/null
+++ b/makima/frontend/src/components/mesh/ContractCompleteQuestion.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import type { PendingQuestion } from "../../lib/api";
+
+interface ContractCompleteQuestionProps {
+ question: PendingQuestion;
+ onAnswer: (questionId: string, response: string) => Promise<void>;
+}
+
+/**
+ * Component for displaying contract_complete type questions prominently on the task page.
+ * These questions persist until answered and are not shown as floating notifications.
+ */
+export function ContractCompleteQuestion({
+ question,
+ onAnswer,
+}: ContractCompleteQuestionProps) {
+ const [submitting, setSubmitting] = useState(false);
+ const [minimized, setMinimized] = useState(false);
+ const [customInput, setCustomInput] = useState("");
+ const [showCustom, setShowCustom] = useState(false);
+
+ const handleAnswer = async (response: string) => {
+ if (submitting) return;
+ setSubmitting(true);
+ try {
+ await onAnswer(question.questionId, response);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleCustomSubmit = async () => {
+ if (!customInput.trim() || submitting) return;
+ await handleAnswer(customInput.trim());
+ setCustomInput("");
+ setShowCustom(false);
+ };
+
+ // Default choices for contract completion questions
+ const defaultChoices =
+ question.choices.length > 0
+ ? question.choices
+ : ["Yes, contract is complete", "No, more work needed"];
+
+ if (minimized) {
+ return (
+ <div className="fixed bottom-4 left-4 z-40">
+ <button
+ onClick={() => setMinimized(false)}
+ className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white font-mono text-sm rounded-lg shadow-lg transition-colors"
+ >
+ <span className="w-2 h-2 bg-white rounded-full animate-pulse" />
+ Contract Review Pending
+ </button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-gradient-to-r from-green-900/40 to-emerald-900/40 border-2 border-green-500/60 rounded-lg shadow-xl my-4 overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between px-4 py-3 bg-green-900/50 border-b border-green-500/30">
+ <div className="flex items-center gap-3">
+ <div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-full">
+ <span className="text-green-400 text-xl">?</span>
+ </div>
+ <div>
+ <h3 className="font-mono text-sm text-green-300 uppercase tracking-wide">
+ Contract Completion Review
+ </h3>
+ <p className="text-xs text-green-400/60">
+ Please review and respond
+ </p>
+ </div>
+ </div>
+ <button
+ onClick={() => setMinimized(true)}
+ className="px-2 py-1 text-xs font-mono text-green-400/70 hover:text-green-300 border border-green-500/30 hover:border-green-400/50 rounded transition-colors"
+ title="Minimize (question will remain pending)"
+ >
+ Minimize
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="p-4 space-y-4">
+ {/* Context */}
+ {question.context && (
+ <div className="text-xs text-green-300/70 font-mono uppercase tracking-wide">
+ {question.context}
+ </div>
+ )}
+
+ {/* Question */}
+ <div className="text-green-100 font-mono text-base leading-relaxed">
+ {question.question}
+ </div>
+
+ {/* Choices */}
+ <div className="flex flex-wrap gap-3 pt-2">
+ {defaultChoices.map((choice, idx) => (
+ <button
+ key={idx}
+ onClick={() => handleAnswer(choice)}
+ disabled={submitting}
+ className={`px-4 py-2.5 font-mono text-sm border rounded-md transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
+ idx === 0
+ ? "bg-green-500/20 border-green-400/60 hover:bg-green-500/30 text-green-100 hover:border-green-400"
+ : "bg-amber-500/20 border-amber-400/60 hover:bg-amber-500/30 text-amber-100 hover:border-amber-400"
+ }`}
+ >
+ {submitting ? "..." : choice}
+ </button>
+ ))}
+ </div>
+
+ {/* Custom input option */}
+ {!showCustom ? (
+ <button
+ onClick={() => setShowCustom(true)}
+ className="text-xs text-green-400/70 hover:text-green-300 font-mono transition-colors"
+ >
+ + Provide custom response
+ </button>
+ ) : (
+ <div className="flex gap-2 pt-2">
+ <input
+ type="text"
+ value={customInput}
+ onChange={(e) => setCustomInput(e.target.value)}
+ placeholder="Type your response..."
+ disabled={submitting}
+ className="flex-1 px-3 py-2 bg-[#0a1525] border border-green-500/30 text-green-100 text-sm font-mono rounded focus:outline-none focus:border-green-400"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && customInput.trim()) {
+ handleCustomSubmit();
+ }
+ if (e.key === "Escape") {
+ setShowCustom(false);
+ setCustomInput("");
+ }
+ }}
+ />
+ <button
+ onClick={handleCustomSubmit}
+ disabled={submitting || !customInput.trim()}
+ className="px-4 py-2 bg-green-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-green-400"
+ >
+ {submitting ? "..." : "Submit"}
+ </button>
+ <button
+ onClick={() => {
+ setShowCustom(false);
+ setCustomInput("");
+ }}
+ className="px-2 py-2 text-green-400/70 hover:text-green-300 text-sm font-mono"
+ >
+ Cancel
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 78e52cd..14ec9f2 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1948,8 +1948,8 @@ export interface PendingQuestion {
createdAt: string;
/** Whether multiple choices can be selected */
multiSelect?: boolean;
- /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions */
- questionType?: "general" | "phase_confirmation";
+ /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions, "contract_complete" for contract completion */
+ questionType?: "general" | "phase_confirmation" | "contract_complete";
/** Phase confirmation specific data (when questionType is "phase_confirmation") */
phaseConfirmation?: {
currentPhase: ContractPhase;
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index a8d3574..142cc54 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -5,6 +5,7 @@ import { TaskList } from "../components/mesh/TaskList";
import { TaskDetail } from "../components/mesh/TaskDetail";
import { TaskOutput } from "../components/mesh/TaskOutput";
import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
+import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQuestion";
import { useTasks } from "../hooks/useTasks";
import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary } from "../lib/api";
@@ -89,6 +90,14 @@ export default function MeshPage() {
[pendingQuestions]
);
+ // Filter contract_complete questions for the current task
+ const contractCompleteQuestionsForTask = useMemo(
+ () => pendingQuestions.filter(
+ (q) => q.questionType === "contract_complete" && q.taskId === id
+ ),
+ [pendingQuestions, id]
+ );
+
// Handler for answering supervisor questions
const handleAnswerQuestion = useCallback(async (questionId: string, response: string) => {
await submitAnswer(questionId, response);
@@ -751,27 +760,41 @@ export default function MeshPage() {
{/* Output panel */}
{(viewMode === "split" || viewMode === "output") && (
<div
- className="panel min-h-0 overflow-hidden flex-1"
+ className="panel min-h-0 overflow-hidden flex-1 flex flex-col"
>
- <TaskOutput
- entries={taskOutputEntries}
- isStreaming={isStreaming || taskDetail.status === "running"}
- viewingSubtaskName={viewingSubtaskName}
- onClearSubtaskView={viewingSubtaskId ? () => {
- setViewingSubtaskId(null);
- setViewingSubtaskName(null);
- } : undefined}
- onClear={() => {
- setTaskOutputEntries([]);
- if (activeOutputTaskId) {
- clearPersistedOutput(activeOutputTaskId);
- }
- }}
- taskId={activeOutputTaskId}
- onUserInput={handleUserInput}
- pendingQuestionIds={pendingQuestionIds}
- onAnswerQuestion={handleAnswerQuestion}
- />
+ {/* Contract complete questions - shown prominently at top */}
+ {contractCompleteQuestionsForTask.length > 0 && (
+ <div className="shrink-0 px-3 pt-3">
+ {contractCompleteQuestionsForTask.map((question) => (
+ <ContractCompleteQuestion
+ key={question.questionId}
+ question={question}
+ onAnswer={handleAnswerQuestion}
+ />
+ ))}
+ </div>
+ )}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <TaskOutput
+ entries={taskOutputEntries}
+ isStreaming={isStreaming || taskDetail.status === "running"}
+ viewingSubtaskName={viewingSubtaskName}
+ onClearSubtaskView={viewingSubtaskId ? () => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } : undefined}
+ onClear={() => {
+ setTaskOutputEntries([]);
+ if (activeOutputTaskId) {
+ clearPersistedOutput(activeOutputTaskId);
+ }
+ }}
+ taskId={activeOutputTaskId}
+ onUserInput={handleUserInput}
+ pendingQuestionIds={pendingQuestionIds}
+ onAnswerQuestion={handleAnswerQuestion}
+ />
+ </div>
</div>
)}
</div>
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index 37aa045..f91ceef 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -354,7 +354,7 @@ async fn run_supervisor(
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let result = client
- .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select)
+ .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs
index bd3aefd..74c27e0 100644
--- a/makima/src/daemon/api/supervisor.rs
+++ b/makima/src/daemon/api/supervisor.rs
@@ -76,6 +76,12 @@ pub struct AskQuestionRequest {
/// When true, allow selecting multiple choices (response will be comma-separated)
#[serde(default)]
pub multi_select: bool,
+ /// When true, return immediately without waiting for response
+ #[serde(default)]
+ pub non_blocking: bool,
+ /// Question type: general, phase_confirmation, or contract_complete
+ #[serde(default)]
+ pub question_type: String,
}
// Generic response type for JSON output
@@ -209,6 +215,8 @@ impl ApiClient {
timeout_seconds: i32,
phaseguard: bool,
multi_select: bool,
+ non_blocking: bool,
+ question_type: String,
) -> Result<JsonValue, ApiError> {
let req = AskQuestionRequest {
question: question.to_string(),
@@ -217,6 +225,8 @@ impl ApiClient {
timeout_seconds,
phaseguard,
multi_select,
+ non_blocking,
+ question_type,
};
self.post("/api/v1/mesh/supervisor/questions", &req).await
}
diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs
index 798a55f..7e17135 100644
--- a/makima/src/daemon/cli/supervisor.rs
+++ b/makima/src/daemon/cli/supervisor.rs
@@ -188,6 +188,14 @@ pub struct AskArgs {
/// Allow selecting multiple choices (response will be comma-separated)
#[arg(long, default_value = "false")]
pub multi_select: bool,
+
+ /// Non-blocking mode - returns immediately without waiting for response
+ #[arg(long, default_value = "false")]
+ pub non_blocking: bool,
+
+ /// Question type (general, phase_confirmation, contract_complete)
+ #[arg(long, default_value = "general")]
+ pub question_type: String,
}
/// Arguments for status command (get contract status including phase).
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
index df8f77c..e5d33c7 100644
--- a/makima/src/server/handlers/mesh_supervisor.rs
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -76,6 +76,16 @@ pub struct AskQuestionRequest {
/// When true, allow selecting multiple choices (response will be comma-separated)
#[serde(default)]
pub multi_select: bool,
+ /// When true, return immediately without waiting for response
+ #[serde(default)]
+ pub non_blocking: bool,
+ /// Question type: general, phase_confirmation, or contract_complete
+ #[serde(default = "default_question_type")]
+ pub question_type: String,
+}
+
+fn default_question_type() -> String {
+ "general".to_string()
}
fn default_question_timeout() -> i32 {
@@ -124,6 +134,9 @@ pub struct PendingQuestionSummary {
/// Whether multiple choices can be selected
#[serde(default)]
pub multi_select: bool,
+ /// Question type: general, phase_confirmation, or contract_complete
+ #[serde(default)]
+ pub question_type: String,
}
/// Request to create a checkpoint.
@@ -1532,6 +1545,7 @@ pub async fn ask_question(
request.choices.clone(),
request.context.clone(),
request.multi_select,
+ request.question_type.clone(),
);
// Broadcast question as task output entry for the task's chat
@@ -1540,6 +1554,7 @@ pub async fn ask_question(
"choices": request.choices,
"context": request.context,
"multi_select": request.multi_select,
+ "question_type": request.question_type,
});
state.broadcast_task_output(TaskOutputNotification {
task_id: supervisor_id,
@@ -1572,6 +1587,18 @@ pub async fn ask_question(
).await;
}
+ // If non_blocking mode is enabled, return immediately with the question_id
+ if request.non_blocking {
+ return (
+ StatusCode::OK,
+ Json(AskQuestionResponse {
+ question_id,
+ response: None,
+ timed_out: false,
+ }),
+ ).into_response();
+ }
+
// Poll for response with timeout
let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64);
let start = std::time::Instant::now();
@@ -1644,6 +1671,7 @@ pub async fn list_pending_questions(
context: q.context,
created_at: q.created_at,
multi_select: q.multi_select,
+ question_type: q.question_type,
})
.collect();
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index c5736af..38aadf5 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -146,6 +146,8 @@ pub struct PendingSupervisorQuestion {
pub created_at: chrono::DateTime<chrono::Utc>,
/// Whether multiple choices can be selected
pub multi_select: bool,
+ /// Question type: general, phase_confirmation, or contract_complete
+ pub question_type: String,
}
/// Response to a supervisor question
@@ -666,6 +668,7 @@ impl AppState {
choices: Vec<String>,
context: Option<String>,
multi_select: bool,
+ question_type: String,
) -> Uuid {
let question_id = Uuid::new_v4();
let now = chrono::Utc::now();
@@ -683,6 +686,7 @@ impl AppState {
context: context.clone(),
created_at: now,
multi_select,
+ question_type: question_type.clone(),
},
);
@@ -704,6 +708,7 @@ impl AppState {
question_id = %question_id,
task_id = %task_id,
contract_id = %contract_id,
+ question_type = %question_type,
"Supervisor question added"
);