diff --git a/apps/tui/package.json b/apps/tui/package.json index 7fca44c..d2fb199 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -27,6 +27,7 @@ "@techatnyu/ralphd": "workspace:*", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", - "react": "^19.2.4" + "react": "^19.2.4", + "zod": "^4.3.6" } } diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 1148bda..b31ca0b 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -1,513 +1,174 @@ -import { basename } from "node:path"; -import { type SelectOption, TextAttributes } from "@opentui/core"; +import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; -import type { - DaemonJob, - DaemonSession, - HealthResult, - ManagedInstance, -} from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; -import { ralphStore, setModelAndRecent } from "../lib/store"; +import { usePlanFiles } from "../hooks/use-plan-files"; +import { usePlanInstance } from "../hooks/use-plan-instance"; import { Chat } from "./chat"; - -type View = - | { type: "dashboard" } - | { - type: "chat"; - instanceId: string; - instanceName: string; - sessionId: string | null; - }; - -interface DashboardData { - health: HealthResult; - instances: ManagedInstance[]; - jobs: DaemonJob[]; -} - -/** Provider IDs sorted by popularity — used to push well-known providers to the top. */ -const PROVIDER_PRIORITY: Record = { - anthropic: 0, - openai: 1, - google: 2, - openrouter: 3, -}; - -const SEPARATOR_VALUE = "__separator__"; - -async function fetchModelOptions(): Promise { - const [result, store] = await Promise.all([ - daemon.providerList({ refresh: true }), - ralphStore.read(), - ]); - const connected = new Set(result.connected); - const recentRefs = new Set(store.recentModels ?? []); - - // Build flat list of all connected models - const allModels: SelectOption[] = result.providers - .filter((provider) => connected.has(provider.id)) - .sort( - (a, b) => - (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || - a.name.localeCompare(b.name), - ) - .flatMap((provider) => - Object.values(provider.models) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((model) => ({ - name: `${provider.name}/${model.name}`, - description: `${provider.id}/${model.id}`, - value: `${provider.id}/${model.id}`, - })), - ); - - // Build recent section from stored order, only including models that still exist - const allByRef = new Map(allModels.map((m) => [m.value, m])); - const recentOptions: SelectOption[] = (store.recentModels ?? []) - .filter((ref) => allByRef.has(ref)) - .map((ref) => allByRef.get(ref) as SelectOption); - - if (recentOptions.length === 0) return allModels; - - // Filter recents out of the "all" section to avoid duplicates - const restModels = allModels.filter( - (m) => !recentRefs.has(m.value as string), - ); - - return [ - { name: "── Recent ──", description: "", value: SEPARATOR_VALUE }, - ...recentOptions, - { name: "── All Models ──", description: "", value: SEPARATOR_VALUE }, - ...restModels, - ]; -} +import { ExecuteView } from "./execute-view"; +import { HelpOverlay } from "./help-overlay"; +import { PlanView } from "./plan-view"; +import { ReviewView } from "./review-view"; +import { StatusBar } from "./status-bar"; interface AppProps { onQuit(): void; } -function clampIndex(index: number, length: number): number { - if (length <= 0) { - return 0; - } - return Math.min(Math.max(index, 0), length - 1); -} +type FocusZone = "tabs" | "content"; -function countJobsByState( - jobs: DaemonJob[], - instanceId: string, -): { running: number; queued: number } { - let running = 0; - let queued = 0; - for (const job of jobs) { - if (job.instanceId !== instanceId) continue; - if (job.state === "running") running++; - else if (job.state === "queued") queued++; - } - return { running, queued }; +interface ActiveChat { + instanceId: string; + instanceName: string; + sessionId: string | null; } -function Dashboard({ - onQuit, - onSelectInstance, -}: { - onQuit(): void; - onSelectInstance( - instance: ManagedInstance, - session: DaemonSession | null, - ): void; -}) { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); - const [data, setData] = useState(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessions, setSessions] = useState([]); - const [selectedSessionIndex, setSelectedSessionIndex] = useState(0); - const [focusPanel, setFocusPanel] = useState<"instances" | "sessions">( - "instances", - ); - const [currentModel, setCurrentModel] = useState(""); - const [modelPicker, setModelPicker] = useState(false); - const [modelOptions, setModelOptions] = useState([]); - const [fetchingModels, setFetchingModels] = useState(false); - const [query, setQuery] = useState(""); - const [cursorOn, setCursorOn] = useState(true); - - useEffect(() => { - if (!modelPicker) return; - const id = setInterval(() => setCursorOn((v) => !v), 500); - return () => clearInterval(id); - }, [modelPicker]); - - const q = query.toLowerCase(); - const visibleOptions = q - ? modelOptions.filter( - (o) => - o.value !== SEPARATOR_VALUE && - (o.name.toLowerCase().includes(q) || - (typeof o.description === "string" && - o.description.toLowerCase().includes(q))), - ) - : modelOptions; +const TAB_OPTIONS = [ + { name: "Plan", description: "" }, + { name: "Execute", description: "" }, + { name: "Review", description: "" }, +]; - const refresh = useCallback( - async (nextIndex = selectedIndex) => { - setLoading(true); - setError(undefined); - try { - const [health, instanceList, storeState] = await Promise.all([ - daemon.health(), - daemon.listInstances(), - ralphStore.read(), - ]); - setCurrentModel(storeState.model); - const safeIndex = clampIndex(nextIndex, instanceList.instances.length); - const selectedInst = instanceList.instances[safeIndex]; - const [jobs, sessionResult] = await Promise.all([ - daemon.listJobs(selectedInst ? { instanceId: selectedInst.id } : {}), - // If the selected instance was removed between `listInstances` - // and this call, the daemon now throws `not_found` instead of - // returning an empty list. Swallow that narrow race so the - // whole refresh doesn't fail. Any other error is a real failure - // and should propagate to the surrounding catch block. - selectedInst - ? daemon.listSessions(selectedInst.id).catch((err) => { - const code = (err as { code?: string } | undefined)?.code; - if (code === "not_found") return { sessions: [] }; - throw err; - }) - : Promise.resolve({ sessions: [] }), - ]); - setSelectedIndex(safeIndex); - setSessions(sessionResult.sessions); - setSelectedSessionIndex(0); - setData({ - health, - instances: instanceList.instances, - jobs: jobs.jobs, - }); - } catch (refreshError) { - setError( - refreshError instanceof Error - ? refreshError.message - : "Failed to load daemon status", - ); - } finally { - setLoading(false); - } - }, - [selectedIndex], - ); +export function App({ onQuit }: AppProps) { + const [activeTab, setActiveTab] = useState(0); + const [focusZone, setFocusZone] = useState("content"); + const [daemonOnline, setDaemonOnline] = useState(true); + const [showHelp, setShowHelp] = useState(false); + const [activeChat, setActiveChat] = useState(null); + const planInstance = usePlanInstance(); + const planFiles = usePlanFiles(planInstance.scaffoldPath); + + const checkDaemon = useCallback(async () => { + try { + await daemon.health(); + setDaemonOnline(true); + } catch { + setDaemonOnline(false); + } + }, []); useEffect(() => { - void refresh(); - }, [refresh]); + void checkDaemon(); + const interval = setInterval(() => void checkDaemon(), 10_000); + return () => clearInterval(interval); + }, [checkDaemon]); useKeyboard((key) => { - if (modelPicker) { - if (key.name === "escape") { - setModelPicker(false); - setQuery(""); - return; - } - if (key.name === "backspace") { - setQuery((prev) => prev.slice(0, -1)); - return; - } + if (activeChat) return; + if (showHelp) { if ( - !key.ctrl && - !key.meta && - typeof key.sequence === "string" && - key.sequence.length === 1 && - key.sequence >= " " && - key.sequence <= "~" + key.name === "escape" || + key.name === "?" || + (key.name === "/" && key.ctrl) ) { - setQuery((prev) => prev + key.sequence); + setShowHelp(false); } return; } - if (key.name === "q" || (key.ctrl && key.name === "c")) { - onQuit(); + if (key.name === "/" && key.ctrl) { + setShowHelp(true); return; } - if (key.name === "r") { - void refresh(); - return; - } - - if (key.name === "m" && !fetchingModels) { - setQuery(""); - setFetchingModels(true); - void fetchModelOptions() - .then((options) => { - setModelOptions(options); - setModelPicker(true); - }) - .catch((err) => { - setError( - err instanceof Error ? err.message : "Failed to fetch models", - ); - }) - .finally(() => setFetchingModels(false)); - return; - } - - if (!data) { - return; - } - - if (key.name === "tab" || key.name === "l" || key.name === "right") { - if (focusPanel === "instances" && sessions.length > 0) { - setFocusPanel("sessions"); + if (key.name === "tab") { + if (focusZone === "tabs") { + setFocusZone("content"); } return; } - if (key.name === "h" || key.name === "left") { - if (focusPanel === "sessions") { - setFocusPanel("instances"); - } + if (key.name === "escape") { + setFocusZone("tabs"); return; } - if (focusPanel === "instances") { - if (key.name === "down" || key.name === "j") { - const next = clampIndex(selectedIndex + 1, data.instances.length); - void refresh(next); - return; - } - - if (key.name === "up" || key.name === "k") { - const next = clampIndex(selectedIndex - 1, data.instances.length); - void refresh(next); + if (focusZone === "tabs") { + if (key.name === "q") { + onQuit(); return; } - - if (key.name === "return") { - const inst = data.instances[selectedIndex]; - if (inst) { - onSelectInstance(inst, null); - } - return; - } - } - - if (focusPanel === "sessions") { - if (key.name === "down" || key.name === "j") { - setSelectedSessionIndex((prev) => - clampIndex(prev + 1, sessions.length + 1), - ); - return; - } - - if (key.name === "up" || key.name === "k") { - setSelectedSessionIndex((prev) => - clampIndex(prev - 1, sessions.length + 1), - ); - return; - } - - if (key.name === "return") { - const inst = data.instances[selectedIndex]; - if (!inst) return; - - // Index 0 is "New Chat", rest are sessions - if (selectedSessionIndex === 0) { - onSelectInstance(inst, null); - } else { - const session = sessions[selectedSessionIndex - 1]; - if (session) { - onSelectInstance(inst, session); - } - } - return; - } - - if (key.name === "escape") { - setFocusPanel("instances"); + if (key.name === "?") { + setShowHelp(true); return; } } }); - const selected = data?.instances[selectedIndex]; + const contentFocused = focusZone === "content"; - if (modelPicker) { + if (activeChat) { return ( - - - Select Model - esc - - - {query ? ( - {`${query}${cursorOn ? "\u2588" : " "}`} - ) : ( - - {`${cursorOn ? "\u2588" : "S"}earch`} - - )} - - + {skill && ( + + {` ${skill.name} `} + + )} + + + + ); +} diff --git a/apps/tui/src/components/plan-view.test.ts b/apps/tui/src/components/plan-view.test.ts new file mode 100644 index 0000000..b46fdbe --- /dev/null +++ b/apps/tui/src/components/plan-view.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { + addTranscriptToPrompt, + buildConversationTranscript, +} from "./plan-view"; + +describe("plan view prompt helpers", () => { + it("builds artifact prompts from visible conversation only", () => { + const transcript = buildConversationTranscript([ + { role: "system", content: "SPEC.md generated." }, + { role: "user", content: "i want a basic todo app" }, + { role: "assistant", content: "Any tech preferences?" }, + { role: "user", content: "actually can we use react" }, + ]); + + expect(transcript).toContain("User: i want a basic todo app"); + expect(transcript).toContain("Ralph: Any tech preferences?"); + expect(transcript).toContain("User: actually can we use react"); + expect(transcript).not.toContain("SPEC.md generated"); + }); + + it("adds the transcript to auto artifact prompts", () => { + const prompt = addTranscriptToPrompt("Generate SPEC.md.", [ + { role: "user", content: "basic todo app" }, + ]); + + expect(prompt).toContain("Generate SPEC.md."); + expect(prompt).toContain("Conversation transcript:"); + expect(prompt).toContain("User: basic todo app"); + }); +}); diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx new file mode 100644 index 0000000..2997d3f --- /dev/null +++ b/apps/tui/src/components/plan-view.tsx @@ -0,0 +1,337 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { type ChatMessage, useChat } from "../hooks/use-chat"; +import type { PlanFilesData } from "../hooks/use-plan-files"; +import type { usePlanInstance } from "../hooks/use-plan-instance"; +import { useSkill } from "../hooks/use-skill"; +import { writePrdArtifact, writeSpecArtifact } from "../lib/plan-artifacts"; +import type { Skill, SkillContext } from "../skills"; +import { ContextSidebar } from "./context-sidebar"; +import { PlanChat } from "./plan-chat"; +import { SpecOverlay } from "./spec-overlay"; +import { TaskOverlay } from "./task-overlay"; + +const SIDEBAR_MIN_WIDTH = 120; + +interface PlanViewProps { + focused: boolean; + planData: PlanFilesData; + daemonOnline: boolean; + planInstance: ReturnType; +} + +async function readSpecForPrompt(scaffoldPath: string): Promise { + const spec = await readFile(join(scaffoldPath, "SPEC.md"), "utf8"); + const trimmed = spec.trim(); + if (!trimmed) { + throw new Error("SPEC.md is empty"); + } + return trimmed; +} + +async function buildSkillPrompt( + skill: Skill, + ctx: SkillContext, + prompt: string, +): Promise { + if (skill.id !== "prd") { + return prompt; + } + const spec = await readSpecForPrompt(ctx.scaffoldPath); + return `${prompt} + +SPEC.md: +\`\`\`markdown +${spec} +\`\`\``; +} + +export function buildConversationTranscript(messages: ChatMessage[]): string { + return messages + .filter((message) => message.role !== "system" && message.content.trim()) + .map((message) => { + const speaker = message.role === "user" ? "User" : "Ralph"; + return `${speaker}: ${message.content.trim()}`; + }) + .join("\n\n"); +} + +export function addTranscriptToPrompt( + prompt: string, + messages: ChatMessage[], +): string { + const transcript = buildConversationTranscript(messages); + if (!transcript) return prompt; + return `${prompt} + +Conversation transcript: +${transcript}`; +} + +async function writeSkillArtifact( + skill: Skill, + ctx: SkillContext, + content: string, +): Promise { + if (skill.id === "spec") { + await writeSpecArtifact(ctx.scaffoldPath, content); + return; + } + if (skill.id === "prd") { + await writePrdArtifact(ctx.scaffoldPath, content); + } +} + +export function PlanView({ + focused, + planData, + daemonOnline, + planInstance, +}: PlanViewProps) { + const [showTasks, setShowTasks] = useState(false); + const [showSpec, setShowSpec] = useState(false); + const { activeSkill, skill, startSkill } = useSkill(); + const { ensure: ensureInstance } = planInstance; + const ensureInstanceId = useCallback( + () => ensureInstance().then((h) => h.instanceId), + [ensureInstance], + ); + const chat = useChat(ensureInstanceId); + const { width } = useTerminalDimensions(); + const showSidebar = width >= SIDEBAR_MIN_WIDTH; + + const { hasSpec, hasPrd, specError, prdError } = planData; + const prevLoading = useRef(chat.loading); + const brainstormHintShown = useRef(false); + const { addSystemMessage, loading: chatLoading } = chat; + + useEffect(() => { + if (!activeSkill) { + startSkill("brainstorm"); + } + }, [activeSkill, startSkill]); + + useEffect(() => { + if ( + activeSkill !== "brainstorm" || + chatLoading || + brainstormHintShown.current + ) { + return; + } + const hasAssistantReply = chat.messages.some( + (message) => message.role === "assistant" && message.content.trim(), + ); + if (!hasAssistantReply) return; + + brainstormHintShown.current = true; + addSystemMessage("Type /spec when you're ready to generate the spec."); + }, [activeSkill, chatLoading, chat.messages, addSystemMessage]); + + useEffect(() => { + const wasLoading = prevLoading.current; + prevLoading.current = chatLoading; + if ( + !wasLoading || + chatLoading || + (activeSkill !== "spec" && activeSkill !== "prd") + ) { + return; + } + + const target = activeSkill; + const timeoutId = setTimeout(() => { + const produced = target === "spec" ? hasSpec : hasPrd; + if (produced) return; + const filename = target === "spec" ? "SPEC.md" : "prd.json"; + const err = target === "spec" ? specError : prdError; + const reason = err + ? `written but invalid (${err}) — review and retry` + : "not written — check model permissions or retry"; + addSystemMessage(`${filename}: ${reason}`); + }, 3000); + return () => clearTimeout(timeoutId); + }, [ + chatLoading, + activeSkill, + hasSpec, + hasPrd, + specError, + prdError, + addSystemMessage, + ]); + + const { send: chatSend, clear: chatClear } = chat; + const sendWithSkill = useCallback( + async (prompt: string) => { + if (!skill) return; + const { scaffoldPath } = await ensureInstance(); + const ctx: SkillContext = { scaffoldPath }; + let finalPrompt: string; + try { + finalPrompt = await buildSkillPrompt(skill, ctx, prompt); + } catch (e) { + addSystemMessage( + `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, + ); + return; + } + const result = await chatSend({ + prompt: finalPrompt, + systemPrompt: skill.buildSystemPrompt(ctx), + permission: skill.buildPermission(ctx), + displayAssistant: skill.id === "brainstorm", + sessionMode: skill.id === "brainstorm" ? "current" : "ephemeral", + }); + if (!result?.content) return; + try { + await writeSkillArtifact(skill, ctx, result.content); + if (skill.id === "spec") { + addSystemMessage( + "SPEC.md updated. Press Ctrl+S to view. Type /prd when ready.", + ); + } else if (skill.id === "prd") { + addSystemMessage( + "prd.json updated. Press Ctrl+T to review tasks, then switch to Execute.", + ); + } + } catch (e) { + const filename = skill.id === "spec" ? "SPEC.md" : "prd.json"; + const reason = + e instanceof Error ? e.message : "generated response was invalid"; + addSystemMessage( + `${filename}: generated response was invalid (${reason})`, + ); + } + }, + [skill, ensureInstance, chatSend, addSystemMessage], + ); + + const handleStartSkill = async (id: "spec" | "prd") => { + const s = startSkill(id); + if (!s.buildAutoPrompt) return; + const { scaffoldPath } = await ensureInstance(); + const ctx: SkillContext = { scaffoldPath }; + let prompt: string; + try { + const autoPrompt = + s.id === "spec" + ? addTranscriptToPrompt(s.buildAutoPrompt(ctx), chat.messages) + : s.buildAutoPrompt(ctx); + prompt = await buildSkillPrompt(s, ctx, autoPrompt); + } catch (e) { + addSystemMessage( + `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, + ); + startSkill("brainstorm"); + return; + } + const result = await chatSend({ + prompt, + systemPrompt: s.buildSystemPrompt(ctx), + permission: s.buildPermission(ctx), + displayAssistant: false, + displayUser: false, + sessionMode: "ephemeral", + }); + try { + if (!result?.content) return; + await writeSkillArtifact(s, ctx, result.content); + if (s.id === "spec") { + addSystemMessage( + "SPEC.md generated. Press Ctrl+S to view. Type /prd when ready.", + ); + } else { + addSystemMessage( + "prd.json generated. Press Ctrl+T to review tasks, then switch to Execute.", + ); + } + } catch (e) { + const filename = s.id === "spec" ? "SPEC.md" : "prd.json"; + const reason = + e instanceof Error ? e.message : "generated response was invalid"; + addSystemMessage( + `${filename}: generated response was invalid (${reason})`, + ); + } finally { + startSkill("brainstorm"); + } + }; + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "t" && key.ctrl) { + setShowTasks((s) => { + const next = !s; + if (next) setShowSpec(false); + return next; + }); + } + if (key.name === "s" && key.ctrl) { + setShowSpec((s) => { + const next = !s; + if (next) setShowTasks(false); + return next; + }); + } + }); + + const toggleTasks = () => { + setShowTasks((s) => { + const next = !s; + if (next) setShowSpec(false); + return next; + }); + }; + + const handleClear = () => { + brainstormHintShown.current = false; + chatClear(); + startSkill("brainstorm"); + }; + + return ( + + + + + {showSidebar && ( + + )} + + + {showSpec && ( + setShowSpec(false)} + /> + )} + + {showTasks && ( + setShowTasks(false)} + /> + )} + + ); +} diff --git a/apps/tui/src/components/review-view.tsx b/apps/tui/src/components/review-view.tsx new file mode 100644 index 0000000..6aa06ca --- /dev/null +++ b/apps/tui/src/components/review-view.tsx @@ -0,0 +1,14 @@ +import { TextAttributes } from "@opentui/core"; + +export function ReviewView() { + return ( + + Review — Coming soon + + ); +} diff --git a/apps/tui/src/components/spec-overlay.tsx b/apps/tui/src/components/spec-overlay.tsx new file mode 100644 index 0000000..efa3598 --- /dev/null +++ b/apps/tui/src/components/spec-overlay.tsx @@ -0,0 +1,52 @@ +import { SyntaxStyle, TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useMemo } from "react"; +import type { PlanFilesData } from "../hooks/use-plan-files"; + +interface SpecOverlayProps { + focused: boolean; + data: PlanFilesData; + onClose: () => void; +} + +export function SpecOverlay({ focused, data, onClose }: SpecOverlayProps) { + const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape" || (key.name === "s" && key.ctrl)) { + onClose(); + } + }); + + return ( + + + {data.hasSpec ? ( + + ) : ( + No SPEC.md generated yet + )} + + + + Esc/Ctrl+S: close + + + ); +} diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx new file mode 100644 index 0000000..aadcf8a --- /dev/null +++ b/apps/tui/src/components/status-bar.tsx @@ -0,0 +1,36 @@ +import { TextAttributes } from "@opentui/core"; +import type { PlanFilesData } from "../hooks/use-plan-files"; + +interface StatusBarProps { + activeTab: number; + planData: PlanFilesData; +} + +const HELP_BY_TAB: Record = { + 0: "Tab: tabs Ctrl+S: spec Ctrl+T: tasks /: commands ?: help", + 1: "Tab: tabs j/k: tasks s: start/resume p: pause c: cancel d: details r: refresh enter: chat ?: help", + 2: "Tab: tabs ?: help", +}; + +function getTaskSummary(planData: PlanFilesData): string { + if (!planData.hasPrd || planData.tasks.length === 0) return ""; + const done = planData.tasks.filter((t) => t.passed).length; + return `${done}/${planData.tasks.length} tasks done`; +} + +export function StatusBar({ activeTab, planData }: StatusBarProps) { + const help = HELP_BY_TAB[activeTab] ?? ""; + const taskSummary = getTaskSummary(planData); + const allDone = + planData.tasks.length > 0 && planData.tasks.every((t) => t.passed); + + return ( + + {help} + + {taskSummary && ( + {taskSummary} + )} + + ); +} diff --git a/apps/tui/src/components/task-overlay.tsx b/apps/tui/src/components/task-overlay.tsx new file mode 100644 index 0000000..95be0bc --- /dev/null +++ b/apps/tui/src/components/task-overlay.tsx @@ -0,0 +1,83 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; +import type { PlanFilesData, PrdTask } from "../hooks/use-plan-files"; + +interface TaskOverlayProps { + focused: boolean; + data: PlanFilesData; + onClose: () => void; +} + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +export function TaskOverlay({ focused, data, onClose }: TaskOverlayProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const { tasks } = data; + const completedCount = tasks.filter((t) => t.passed).length; + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape") { + onClose(); + return; + } + if (key.name === "down" || key.name === "j") { + setSelectedIndex((i) => clampIndex(i + 1, tasks.length)); + } + if (key.name === "up" || key.name === "k") { + setSelectedIndex((i) => clampIndex(i - 1, tasks.length)); + } + }); + + return ( + + + {tasks.length > 0 ? ( + tasks.map((task: PrdTask, index: number) => { + const isSelected = focused && index === selectedIndex; + const icon = task.passed ? "✓" : "○"; + + return ( + + {`${icon} `} + + {task.description} + + + ); + }) + ) : ( + No prd.json generated yet + )} + + + + j/k: navigate Esc: close + + + ); +} diff --git a/apps/tui/src/components/welcome-screen.tsx b/apps/tui/src/components/welcome-screen.tsx new file mode 100644 index 0000000..c9cac2c --- /dev/null +++ b/apps/tui/src/components/welcome-screen.tsx @@ -0,0 +1,109 @@ +import { TextAttributes } from "@opentui/core"; +import type { Skill } from "../skills"; + +interface WelcomeScreenProps { + skill: Skill | undefined; +} + +export function WelcomeScreen({ skill }: WelcomeScreenProps) { + if (skill?.id === "brainstorm") { + return ( + + Plan + + Describe what you want to build. + + + When ready, type /spec to generate the spec. + + + ); + } + + if (skill) { + return ( + + {skill.name} + + {skill.inputPlaceholder} + + + ); + } + + return ( + + ralph + + AI-powered project planning + + + + Skills: + + /spec + + {" Generate project spec (.ralph/SPEC.md)"} + + + + /prd + + {" Create task breakdown (.ralph/prd.json)"} + + + + + + Commands: + + /tasks + {" Toggle task list"} + + + /clear + + {" Clear chat messages"} + + + + + + Shortcuts: + + Ctrl+T + + {" Toggle task list"} + + + + Ctrl+N / P + + {" Next / previous suggestion"} + + + + @file + + {" Reference a file"} + + + + + ); +} diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts new file mode 100644 index 0000000..8ad3167 --- /dev/null +++ b/apps/tui/src/hooks/use-chat.ts @@ -0,0 +1,183 @@ +import { type DaemonJob, daemon, type PermissionRule } from "@techatnyu/ralphd"; +import { useCallback, useRef, useState } from "react"; +import { createPromptTask } from "../lib/prompt-task"; +import { ralphStore } from "../lib/store"; + +export interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; +} + +export interface SendOptions { + prompt: string; + systemPrompt: string; + permission?: PermissionRule[]; + displayAssistant?: boolean; + displayUser?: boolean; + sessionMode?: "current" | "ephemeral"; +} + +export interface SendResult { + content: string; + job?: DaemonJob; +} + +interface UseChatReturn { + messages: ChatMessage[]; + loading: boolean; + error: string | undefined; + send: (options: SendOptions) => Promise; + addSystemMessage: (content: string) => void; + resetSession: () => void; + clear: () => void; +} + +export function useChat(ensureInstance: () => Promise): UseChatReturn { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const sessionIdRef = useRef(null); + const cancelledRef = useRef(false); + + const addSystemMessage = useCallback((content: string) => { + setMessages((prev) => [...prev, { role: "system", content }]); + }, []); + + const resetSession = useCallback(() => { + sessionIdRef.current = null; + }, []); + + const updateLastMessage = useCallback( + (updater: (msg: ChatMessage) => ChatMessage) => { + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last) next[next.length - 1] = updater(last); + return next; + }); + }, + [], + ); + + const send = useCallback( + async ({ + prompt, + systemPrompt, + permission, + displayAssistant = true, + displayUser = true, + sessionMode = "current", + }: SendOptions) => { + if (loading) return undefined; + + if (displayUser) { + setMessages((prev) => [...prev, { role: "user", content: prompt }]); + } + setLoading(true); + setError(undefined); + cancelledRef.current = false; + + try { + const instanceId = await ensureInstance(); + + const session = + sessionMode === "current" && sessionIdRef.current + ? { type: "existing" as const, sessionId: sessionIdRef.current } + : { type: "new" as const, title: "Plan", permission }; + + const { model: storedModel } = await ralphStore.read(); + const { events } = await daemon.submitAndStreamJob({ + instanceId, + session, + task: { + ...createPromptTask({ prompt, storedModel }), + system: systemPrompt, + }, + }); + + if (displayAssistant) { + setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + } + let content = ""; + + for await (const event of events) { + if (cancelledRef.current) break; + + if (event.type === "snapshot") { + content = event.text; + if (displayAssistant) { + updateLastMessage(() => ({ role: "assistant", content })); + } + } else if (event.type === "delta" && event.field === "text") { + content += event.delta; + if (displayAssistant) { + updateLastMessage(() => ({ role: "assistant", content })); + } + } else if (event.type === "done") { + if (sessionMode === "current" && event.job.sessionId) { + sessionIdRef.current = event.job.sessionId; + } + if (event.job.state === "failed") { + const message = event.job.error || "Job failed"; + setError(message); + if (displayAssistant) { + updateLastMessage(() => ({ + role: "system", + content: `Error: ${message}`, + })); + } + return undefined; + } + if (event.job.state !== "succeeded") { + return undefined; + } + let final = content.trim(); + if (!content.trim()) { + final = event.job.outputText?.trim() || ""; + if (displayAssistant) { + updateLastMessage(() => ({ + role: "assistant", + content: final || "(empty response)", + })); + } + } + return { content: final, job: event.job }; + } else if (event.type === "error") { + setError(event.error); + if (displayAssistant) { + updateLastMessage(() => ({ + role: "system", + content: `Error: ${event.error}`, + })); + } + return undefined; + } + } + return undefined; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to submit message"); + return undefined; + } finally { + setLoading(false); + } + }, + [loading, ensureInstance, updateLastMessage], + ); + + const clear = useCallback(() => { + cancelledRef.current = true; + setMessages([]); + sessionIdRef.current = null; + setError(undefined); + }, []); + + return { + messages, + loading, + error, + send, + addSystemMessage, + resetSession, + clear, + }; +} diff --git a/apps/tui/src/hooks/use-file-search.ts b/apps/tui/src/hooks/use-file-search.ts new file mode 100644 index 0000000..702e4e6 --- /dev/null +++ b/apps/tui/src/hooks/use-file-search.ts @@ -0,0 +1,90 @@ +import { readdir } from "node:fs/promises"; +import { relative } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + ".ralph", + ".ralph-dev", + "dist", + "build", + ".turbo", + ".next", +]); + +async function listFiles(dir: string): Promise { + const results: string[] = []; + try { + const entries = await readdir(dir, { + withFileTypes: true, + recursive: true, + }); + for (const entry of entries) { + if (entry.isFile()) { + const parent = + "parentPath" in entry + ? (entry.parentPath as string) + : (entry as unknown as { path: string }).path; + const fullPath = `${parent}/${entry.name}`; + const rel = relative(dir, fullPath); + const shouldIgnore = rel + .split("/") + .some((segment) => IGNORE_DIRS.has(segment)); + if (!shouldIgnore) { + results.push(rel); + } + } + } + } catch { + // directory may not exist + } + return results; +} + +function scoreMatch(file: string, query: string): number { + const lower = file.toLowerCase(); + const q = query.toLowerCase(); + const basename = lower.split("/").pop() ?? lower; + if (basename.startsWith(q)) return 3; + if (basename.includes(q)) return 2; + if (lower.includes(q)) return 1; + return 0; +} + +interface UseFileSearchReturn { + results: string[]; + loading: boolean; +} + +export function useFileSearch(query: string): UseFileSearchReturn { + const [allFiles, setAllFiles] = useState([]); + const [loading, setLoading] = useState(false); + const loadedRef = useRef(false); + + const scan = useCallback(async () => { + if (loadedRef.current) return; + setLoading(true); + const files = await listFiles(process.cwd()); + setAllFiles(files); + loadedRef.current = true; + setLoading(false); + }, []); + + useEffect(() => { + void scan(); + }, [scan]); + + if (!query) { + return { results: allFiles.slice(0, 20), loading }; + } + + const scored = allFiles + .map((file) => ({ file, score: scoreMatch(file, query) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(({ file }) => file); + + return { results: scored, loading }; +} diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts new file mode 100644 index 0000000..f8979f4 --- /dev/null +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -0,0 +1,114 @@ +import { readFile, watch } from "node:fs"; +import { join } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { type PrdTask, parsePrd, validateSpec } from "../lib/plan-validation"; + +export type { PrdTask } from "../lib/plan-validation"; + +export interface PlanFilesData { + tasks: PrdTask[]; + progress: string; + specContent: string; + hasSpec: boolean; + hasPrd: boolean; + specError?: string; + prdError?: string; +} + +interface UsePlanFilesReturn { + data: PlanFilesData; + loading: boolean; + error: string | undefined; + refresh: () => Promise; +} + +function readFileAsync(path: string): Promise { + return new Promise((resolve) => { + readFile(path, "utf-8", (err, data) => { + if (err) { + resolve(null); + } else { + resolve(data); + } + }); + }); +} + +export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { + const [data, setData] = useState({ + tasks: [], + progress: "", + specContent: "", + hasSpec: false, + hasPrd: false, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const debounceRef = useRef | null>(null); + + const loadFiles = useCallback(async () => { + if (!scaffoldPath) { + setData({ + tasks: [], + progress: "", + specContent: "", + hasSpec: false, + hasPrd: false, + }); + return; + } + setLoading(true); + setError(undefined); + try { + const [prdContent, progressContent, specContent] = await Promise.all([ + readFileAsync(join(scaffoldPath, "prd.json")), + readFileAsync(join(scaffoldPath, "progress.md")), + readFileAsync(join(scaffoldPath, "SPEC.md")), + ]); + const prdResult = parsePrd(prdContent); + const specResult = validateSpec(specContent); + setData({ + tasks: prdResult.tasks, + progress: progressContent ?? "", + specContent: specContent ?? "", + hasSpec: specContent !== null && specResult.valid, + hasPrd: prdContent !== null && !prdResult.error, + specError: + specContent !== null && !specResult.valid + ? specResult.error + : undefined, + prdError: + prdContent !== null && prdResult.error ? prdResult.error : undefined, + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to read plan files"); + } finally { + setLoading(false); + } + }, [scaffoldPath]); + + useEffect(() => { + void loadFiles(); + + if (!scaffoldPath) return; + + let watcher: ReturnType | null = null; + try { + watcher = watch(scaffoldPath, { recursive: true }, () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void loadFiles(); + }, 500); + }); + } catch { + // scaffoldPath may not exist yet — load will create it implicitly + } + + return () => { + watcher?.close(); + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [loadFiles, scaffoldPath]); + + return { data, loading, error, refresh: loadFiles }; +} diff --git a/apps/tui/src/hooks/use-plan-instance.ts b/apps/tui/src/hooks/use-plan-instance.ts new file mode 100644 index 0000000..143f2fd --- /dev/null +++ b/apps/tui/src/hooks/use-plan-instance.ts @@ -0,0 +1,98 @@ +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createProjectSlug, + ensureProjectStore, + resolveProjectRoot, +} from "../lib/project-store"; + +export interface PlanInstanceHandle { + instanceId: string; + scaffoldPath: string; + projectRoot: string; + projectSlug: string; +} + +interface UsePlanInstanceReturn { + instanceId: string | null; + scaffoldPath: string | null; + projectRoot: string | null; + projectSlug: string | null; + loading: boolean; + error: string | undefined; + ensure: () => Promise; +} + +export function usePlanInstance(): UsePlanInstanceReturn { + const [instanceId, setInstanceId] = useState(null); + const [scaffoldPath, setScaffoldPath] = useState(null); + const [projectRoot, setProjectRoot] = useState(null); + const [projectSlug, setProjectSlug] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const resolving = useRef | null>(null); + + const ensure = useCallback(async (): Promise => { + if (instanceId && scaffoldPath && projectRoot && projectSlug) { + return { instanceId, scaffoldPath, projectRoot, projectSlug }; + } + if (resolving.current) return resolving.current; + + const resolve = async (): Promise => { + setLoading(true); + setError(undefined); + try { + const root = await resolveProjectRoot(process.cwd()); + const slug = createProjectSlug(root); + const { instances } = await daemon.listInstances(); + const existing = instances.find((i) => i.directory === root); + const id = existing + ? existing.id + : (await daemon.createInstance({ name: slug, directory: root })) + .instance.id; + + const store = await ensureProjectStore({ + projectRoot: root, + legacyInstanceId: id, + }); + setInstanceId(id); + setScaffoldPath(store.storeDir); + setProjectRoot(store.projectRoot); + setProjectSlug(store.slug); + return { + instanceId: id, + scaffoldPath: store.storeDir, + projectRoot: store.projectRoot, + projectSlug: store.slug, + }; + } catch (e) { + const msg = + e instanceof Error ? e.message : "Failed to resolve instance"; + setError(msg); + throw new Error(msg); + } finally { + setLoading(false); + resolving.current = null; + } + }; + + resolving.current = resolve(); + return resolving.current; + }, [instanceId, scaffoldPath, projectRoot, projectSlug]); + + useEffect(() => { + void ensure().catch(() => { + // The error state is set inside ensure; callers can retry explicitly. + }); + }, [ensure]); + + return { + instanceId, + scaffoldPath, + projectRoot, + projectSlug, + loading, + error, + ensure, + }; +} diff --git a/apps/tui/src/hooks/use-skill.ts b/apps/tui/src/hooks/use-skill.ts new file mode 100644 index 0000000..d8f15f0 --- /dev/null +++ b/apps/tui/src/hooks/use-skill.ts @@ -0,0 +1,33 @@ +import { useCallback, useState } from "react"; +import { + type ActiveSkill, + getSkill, + type Skill, + type SkillId, +} from "../skills"; + +interface UseSkillReturn { + activeSkill: ActiveSkill; + skill: Skill | undefined; + startSkill: (id: SkillId) => Skill; + clearSkill: () => void; +} + +export function useSkill(): UseSkillReturn { + const [activeSkill, setActiveSkill] = useState(null); + + const skill = activeSkill ? getSkill(activeSkill) : undefined; + + const startSkill = useCallback((id: SkillId): Skill => { + const s = getSkill(id); + if (!s) throw new Error(`Unknown skill: ${id}`); + setActiveSkill(id); + return s; + }, []); + + const clearSkill = useCallback(() => { + setActiveSkill(null); + }, []); + + return { activeSkill, skill, startSkill, clearSkill }; +} diff --git a/apps/tui/src/lib/execute-view-model.test.ts b/apps/tui/src/lib/execute-view-model.test.ts new file mode 100644 index 0000000..14f22a9 --- /dev/null +++ b/apps/tui/src/lib/execute-view-model.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "bun:test"; +import type { DaemonJob } from "@techatnyu/ralphd"; +import { + buildExecuteViewModel, + readLatestProgressEntry, +} from "./execute-view-model"; +import type { LoopState, TaskAttempt } from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; + +const NOW = "2026-04-28T00:00:00.000Z"; + +const TASK_0: PrdTask = { + description: "Set up project structure", + subtasks: ["Create files"], + notes: "", + passed: false, +}; + +const TASK_1: PrdTask = { + description: "Render task list", + subtasks: ["Show tasks"], + notes: "", + passed: false, +}; + +const TASKS: PrdTask[] = [TASK_0, TASK_1]; + +function attempt( + partial: Partial & Pick, +): TaskAttempt { + const { id, taskIndex, status, ...rest } = partial; + return { + id: id ?? `${taskIndex}-attempt`, + taskIndex, + taskDescription: TASKS[taskIndex]?.description ?? "Unknown", + attemptNumber: 1, + status, + updatedAt: NOW, + ...rest, + }; +} + +function loopState( + partial: Partial & { attempts?: TaskAttempt[] } = {}, +): LoopState { + return { + version: 1, + projectRoot: "/tmp/project", + status: "running", + attempts: [], + createdAt: NOW, + updatedAt: NOW, + ...partial, + }; +} + +function job(partial: Partial & Pick): DaemonJob { + const { id, ...rest } = partial; + return { + id, + instanceId: "instance-1", + sessionId: "session-1", + task: { + type: "prompt", + prompt: "You are Ralph's execution agent for one PRD task attempt.", + }, + state: "running", + createdAt: NOW, + updatedAt: NOW, + ...rest, + }; +} + +describe("execute view model", () => { + it("renders pending tasks when there are no attempts", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + }); + + expect(model.status).toBe("idle"); + expect(model.rows.map((row) => row.status)).toEqual(["pending", "pending"]); + expect(model.rows[0]?.description).toBe("Set up project structure"); + }); + + it("shows the running active attempt with its job and session", () => { + const active = attempt({ + taskIndex: 1, + status: "running", + jobId: "job-1", + sessionId: "session-1", + }); + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + currentTaskIndex: 1, + attempts: [active], + }), + jobs: [job({ id: "job-1", state: "running" })], + }); + + expect(model.currentTaskIndex).toBe(1); + expect(model.activeAttempt?.id).toBe(active.id); + expect(model.activeJob?.id).toBe("job-1"); + expect(model.rows[1]).toMatchObject({ + status: "running", + jobId: "job-1", + sessionId: "session-1", + }); + }); + + it("renders verified attempts with warnings as warning rows", () => { + const model = buildExecuteViewModel({ + tasks: [{ ...TASK_0, passed: true }], + progress: "Task 1: Set up project structure finished.", + loopState: loopState({ + status: "completed", + attempts: [ + attempt({ + taskIndex: 0, + status: "verified", + jobId: "job-1", + verificationWarnings: ["verified without sentinel"], + }), + ], + }), + jobs: [job({ id: "job-1", state: "succeeded" })], + }); + + expect(model.rows[0]?.status).toBe("warning"); + expect(model.latestWarning).toBe("verified without sentinel"); + }); + + it("surfaces failed and needs_attention verification errors", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + status: "needs_attention", + lastVerificationFailure: "progress.md was not appended", + attempts: [ + attempt({ + taskIndex: 0, + status: "needs_attention", + verificationErrors: ["progress.md was not appended"], + }), + attempt({ + taskIndex: 1, + status: "failed", + verificationErrors: ["Job failed"], + }), + ], + }), + }); + + expect(model.status).toBe("needs_attention"); + expect(model.blockingMessage).toBe("progress.md was not appended"); + expect(model.rows[0]?.status).toBe("needs_attention"); + expect(model.rows[1]?.status).toBe("failed"); + }); + + it("shows completed when all tasks passed even if loop state is paused", () => { + const model = buildExecuteViewModel({ + tasks: TASKS.map((task) => ({ ...task, passed: true })), + progress: "", + loopState: loopState({ + status: "paused", + currentTaskIndex: 1, + attempts: [ + attempt({ taskIndex: 0, status: "verified" }), + attempt({ taskIndex: 1, status: "verified" }), + ], + }), + }); + + expect(model.status).toBe("completed"); + expect(model.completedTasks).toBe(2); + }); + + it("uses PRD descriptions instead of daemon prompt snippets", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + attempts: [ + attempt({ + taskIndex: 0, + status: "running", + jobId: "job-1", + }), + ], + }), + jobs: [ + job({ + id: "job-1", + task: { + type: "prompt", + prompt: "You are Ralph's execution agent for one PRD task attempt.", + }, + }), + ], + }); + + expect(model.rows[0]?.description).toBe("Set up project structure"); + expect(model.rows[0]?.description).not.toContain("execution agent"); + }); + + it("finds the latest progress entry for a task", () => { + const entry = readLatestProgressEntry( + [ + "# Progress Log", + "", + "Task 1: Set up project structure finished.", + "", + "Task 2: Render task list with empty state.", + ].join("\n"), + 1, + "Render task list", + ); + + expect(entry).toBe("Task 2: Render task list with empty state."); + }); +}); diff --git a/apps/tui/src/lib/execute-view-model.ts b/apps/tui/src/lib/execute-view-model.ts new file mode 100644 index 0000000..d67d594 --- /dev/null +++ b/apps/tui/src/lib/execute-view-model.ts @@ -0,0 +1,214 @@ +import type { DaemonJob } from "@techatnyu/ralphd"; +import { + getActiveAttempt, + type LoopState, + type TaskAttempt, +} from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; + +export type ExecuteTaskStatus = + | "pending" + | "queued" + | "running" + | "verified" + | "warning" + | "failed" + | "cancelled" + | "needs_attention"; + +export type ExecuteDisplayStatus = + | "idle" + | "running" + | "paused" + | "needs_attention" + | "completed"; + +export interface ExecuteTaskRow { + index: number; + description: string; + task: PrdTask; + status: ExecuteTaskStatus; + statusText: string; + attemptCount: number; + latestAttempt?: TaskAttempt; + job?: DaemonJob; + jobId?: string; + sessionId?: string; + warnings: string[]; + errors: string[]; + progressEntry?: string; +} + +export interface ExecuteDisplayState { + status: ExecuteDisplayStatus; + completedTasks: number; + totalTasks: number; + currentTaskIndex?: number; + activeAttempt?: TaskAttempt; + activeJob?: DaemonJob; + blockingMessage?: string; + latestWarning?: string; + rows: ExecuteTaskRow[]; +} + +export interface BuildExecuteViewModelInput { + tasks: PrdTask[]; + progress: string; + loopState?: LoopState; + jobs?: DaemonJob[]; +} + +function jobById(jobs: DaemonJob[] | undefined): Map { + return new Map((jobs ?? []).map((job) => [job.id, job])); +} + +function truncateText(text: string, maxLength: number): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + if (maxLength <= 3) return normalized.slice(0, maxLength); + return `${normalized.slice(0, maxLength - 3)}...`; +} + +function latestAttemptForTask( + attempts: TaskAttempt[], + taskIndex: number, +): TaskAttempt | undefined { + return [...attempts] + .reverse() + .find((attempt) => attempt.taskIndex === taskIndex); +} + +function statusFromAttempt( + task: PrdTask, + attempt: TaskAttempt | undefined, +): Pick { + if (!attempt) { + return task.passed + ? { status: "verified", statusText: "verified" } + : { status: "pending", statusText: "pending" }; + } + + if ( + attempt.status === "verified" && + (attempt.verificationWarnings?.length ?? 0) > 0 + ) { + return { status: "warning", statusText: "warning" }; + } + + if (attempt.status === "succeeded") { + return { status: "running", statusText: "verifying" }; + } + + if (attempt.status === "verified") { + return { status: "verified", statusText: "verified" }; + } + + return { status: attempt.status, statusText: attempt.status }; +} + +export function readLatestProgressEntry( + progress: string, + taskIndex: number, + taskDescription: string, +): string | undefined { + const blocks = progress + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter((block) => block && block !== "# Progress Log"); + const normalizedDescription = taskDescription.trim().toLowerCase(); + const descriptionPrefix = normalizedDescription.slice( + 0, + Math.min(normalizedDescription.length, 80), + ); + const taskTerms = [ + `task ${taskIndex + 1}`, + `task index ${taskIndex}`, + `tasks[${taskIndex}]`, + ]; + + for (const block of [...blocks].reverse()) { + const normalized = block.toLowerCase(); + if ( + (descriptionPrefix && normalized.includes(descriptionPrefix)) || + taskTerms.some((term) => normalized.includes(term)) + ) { + return truncateText(block, 240); + } + } + + const fallback = blocks.at(-1); + return fallback ? truncateText(fallback, 240) : undefined; +} + +export function buildExecuteViewModel({ + tasks, + progress, + loopState, + jobs, +}: BuildExecuteViewModelInput): ExecuteDisplayState { + const jobsById = jobById(jobs); + const activeAttempt = loopState ? getActiveAttempt(loopState) : undefined; + const activeJob = activeAttempt?.jobId + ? jobsById.get(activeAttempt.jobId) + : undefined; + const completedTasks = tasks.filter((task) => task.passed).length; + const allDone = tasks.length > 0 && completedTasks === tasks.length; + const status: ExecuteDisplayStatus = + allDone && !activeAttempt ? "completed" : (loopState?.status ?? "idle"); + + const rows = tasks.map((task, index): ExecuteTaskRow => { + const latestAttempt = latestAttemptForTask( + loopState?.attempts ?? [], + index, + ); + const job = latestAttempt?.jobId + ? jobsById.get(latestAttempt.jobId) + : undefined; + const statusInfo = statusFromAttempt(task, latestAttempt); + const warnings = latestAttempt?.verificationWarnings ?? []; + const errors = latestAttempt?.verificationErrors ?? []; + return { + index, + description: task.description, + task, + ...statusInfo, + attemptCount: + loopState?.attempts.filter((attempt) => attempt.taskIndex === index) + .length ?? 0, + latestAttempt, + job, + jobId: latestAttempt?.jobId, + sessionId: latestAttempt?.sessionId ?? job?.sessionId, + warnings, + errors, + progressEntry: readLatestProgressEntry(progress, index, task.description), + }; + }); + + const firstPending = rows.find((row) => row.status === "pending")?.index; + const latestAttempt = loopState?.attempts.at(-1); + const currentTaskIndex = + activeAttempt?.taskIndex ?? + loopState?.currentTaskIndex ?? + firstPending ?? + latestAttempt?.taskIndex; + const latestWarning = [...rows] + .reverse() + .find((row) => row.warnings.length) + ?.warnings.join(", "); + const blockingMessage = + loopState?.lastVerificationFailure ?? + rows.find((row) => row.errors.length)?.errors.join("; "); + + return { + status, + completedTasks, + totalTasks: tasks.length, + currentTaskIndex, + activeAttempt, + activeJob, + blockingMessage, + latestWarning, + rows, + }; +} diff --git a/apps/tui/src/lib/execution-loop.test.ts b/apps/tui/src/lib/execution-loop.test.ts new file mode 100644 index 0000000..91e6358 --- /dev/null +++ b/apps/tui/src/lib/execution-loop.test.ts @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + DaemonJob, + JobSession, + JobTask, + ManagedInstance, +} from "@techatnyu/ralphd"; +import { + acceptActiveAttempt, + advanceExecutionLoop, + buildExecutionPrompt, + type ExecutionDaemon, + markActiveAttemptCancelled, + markLoopPaused, + TASK_COMPLETE_SENTINEL, + type VerificationSnapshot, + verifyTaskCompletion, +} from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; +import { ensureProjectStore, type ProjectStorePaths } from "./project-store"; + +const TASK_0: PrdTask = { + description: "Set up project structure and HTML skeleton", + subtasks: ["Create index.html", "Add app container"], + notes: "", + passed: false, +}; + +const TASK_1: PrdTask = { + description: "Add task input and persistence", + subtasks: ["Create form", "Save tasks locally"], + notes: "", + passed: false, +}; + +const TASKS: PrdTask[] = [TASK_0, TASK_1]; + +class FakeDaemon implements ExecutionDaemon { + instances: ManagedInstance[] = []; + jobs: DaemonJob[] = []; + submittedPrompts: string[] = []; + + async listInstances(): Promise<{ instances: ManagedInstance[] }> { + return { instances: this.instances }; + } + + async createInstance(params: { + name: string; + directory: string; + maxConcurrency?: number; + }): Promise<{ instance: ManagedInstance }> { + const now = new Date().toISOString(); + const instance: ManagedInstance = { + id: `instance-${this.instances.length}`, + name: params.name, + directory: params.directory, + status: "running", + maxConcurrency: params.maxConcurrency ?? 1, + createdAt: now, + updatedAt: now, + }; + this.instances.push(instance); + return { instance }; + } + + async submitJob(params: { + instanceId: string; + session: JobSession; + task: JobTask; + }): Promise<{ job: DaemonJob }> { + const now = new Date().toISOString(); + const job: DaemonJob = { + id: `job-${this.jobs.length}`, + instanceId: params.instanceId, + sessionId: `session-${this.jobs.length}`, + task: params.task, + state: "running", + createdAt: now, + updatedAt: now, + startedAt: now, + }; + this.jobs.push(job); + this.submittedPrompts.push(params.task.prompt); + return { job }; + } + + async getJob(jobId: string): Promise<{ job: DaemonJob }> { + const job = this.jobs.find((candidate) => candidate.id === jobId); + if (!job) throw new Error(`missing job ${jobId}`); + return { job }; + } + + completeJob(jobId: string, outputText = TASK_COMPLETE_SENTINEL): void { + const index = this.jobs.findIndex((job) => job.id === jobId); + const job = this.jobs[index]; + if (!job) throw new Error(`missing job ${jobId}`); + this.jobs[index] = { + ...job, + state: "succeeded", + outputText, + endedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } +} + +describe("execution loop", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + async function createStore(): Promise { + const projectRoot = await tempDir("ralph-loop-project-"); + const ralphHome = await tempDir("ralph-loop-home-"); + await mkdir(projectRoot, { recursive: true }); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + const paths = await ensureProjectStore({ projectRoot, ralphHome }); + await writePrd(paths, TASKS); + await writeFile(paths.progressPath, "# Progress Log\n", "utf8"); + return paths; + } + + async function writePrd( + paths: ProjectStorePaths, + tasks: PrdTask[], + ): Promise { + await writeFile( + paths.prdPath, + `${JSON.stringify({ tasks }, null, "\t")}\n`, + "utf8", + ); + } + + async function appendProgress( + paths: ProjectStorePaths, + text: string, + ): Promise { + const current = await readFile(paths.progressPath, "utf8"); + await writeFile(paths.progressPath, `${current}\n${text}\n`, "utf8"); + } + + it("builds prompts with user-root artifact paths and the exact task index", async () => { + const paths = await createStore(); + const prompt = buildExecutionPrompt({ + paths, + task: TASK_1, + taskIndex: 1, + totalTasks: TASKS.length, + }); + + expect(prompt).toContain(paths.specPath); + expect(prompt).toContain(paths.prdPath); + expect(prompt).toContain(paths.progressPath); + expect(prompt).toContain("task index 1"); + expect(prompt).toContain("tasks[1].passed"); + }); + + it("verifies a completed task and reports contract failures", async () => { + const paths = await createStore(); + const before: VerificationSnapshot = { + progressLength: (await readFile(paths.progressPath, "utf8")).length, + gitRepository: false, + gitHead: null, + }; + const passedTasks = [{ ...TASK_0, passed: true }, TASK_1]; + await writePrd(paths, passedTasks); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + + const ok = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before, + }); + expect(ok).toEqual({ ok: true, errors: [], warnings: [] }); + + const missingSentinel = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", "done"), + before, + }); + expect(missingSentinel).toEqual({ + ok: true, + errors: [], + warnings: ["verified without sentinel"], + }); + + await writePrd(paths, TASKS); + const unchangedPrd = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before, + }); + expect(unchangedPrd.errors).toContain("tasks[0].passed is not true"); + + const missingSentinelWithFailedDurable = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", "done"), + before, + }); + expect(missingSentinelWithFailedDurable.errors).toContain( + "tasks[0].passed is not true", + ); + expect(missingSentinelWithFailedDurable.errors).toContain( + `job output is missing ${TASK_COMPLETE_SENTINEL}`, + ); + + const unchangedProgress = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before: { + ...before, + progressLength: (await readFile(paths.progressPath, "utf8")).length, + }, + }); + expect(unchangedProgress.errors).toContain("progress.md was not appended"); + + const failedJob = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("failed", TASK_COMPLETE_SENTINEL), + before, + }); + expect(failedJob.errors).toContain("job ended as failed"); + + const missingCommit = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before: { ...before, gitRepository: true, gitHead: "abc" }, + readGitHead: async () => "abc", + }); + expect(missingCommit.errors).toContain( + "git HEAD did not advance during the attempt", + ); + }); + + it("runs pending tasks sequentially without resubmitting a verified task", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const first = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(first.action).toBe("submitted"); + expect(fakeDaemon.submittedPrompts).toHaveLength(1); + expect(fakeDaemon.submittedPrompts[0]).toContain("task index 0"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + fakeDaemon.completeJob("job-0"); + + const verified = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(verified.action).toBe("verified"); + expect(fakeDaemon.submittedPrompts).toHaveLength(1); + + const second = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(second.action).toBe("submitted"); + expect(fakeDaemon.submittedPrompts).toHaveLength(2); + expect(fakeDaemon.submittedPrompts[1]).toContain("task index 1"); + }); + + it("advances after a sentinel-only miss when durable checks pass", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const first = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(first.action).toBe("submitted"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + fakeDaemon.completeJob("job-0", "All durable work is done."); + + const verified = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(verified.action).toBe("verified"); + expect(verified.attempt?.verificationWarnings).toEqual([ + "verified without sentinel", + ]); + + const second = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(second.action).toBe("submitted"); + expect(second.attempt?.taskIndex).toBe(1); + }); + + it("accepts a needs_attention attempt and resumes at the next pending task", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + fakeDaemon.completeJob("job-0", "no sentinel and no durable updates"); + const paused = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(paused.state.status).toBe("needs_attention"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + const accepted = await acceptActiveAttempt(paths, { + warning: "verified without sentinel", + }); + expect(accepted.status).toBe("running"); + expect(accepted.attempts[0]?.status).toBe("verified"); + expect(accepted.attempts[0]?.verificationWarnings).toEqual([ + "verified without sentinel", + ]); + + const resumed = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(resumed.action).toBe("submitted"); + expect(resumed.attempt?.taskIndex).toBe(1); + }); + + it("pauses without cancelling and cancellation leaves the task pending", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const submitted = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(submitted.action).toBe("submitted"); + + const paused = await markLoopPaused(paths); + expect(paused.status).toBe("paused"); + expect(paused.attempts[0]?.status).toBe("running"); + + const cancelled = await markActiveAttemptCancelled(paths); + expect(cancelled.status).toBe("paused"); + expect(cancelled.attempts[0]?.status).toBe("cancelled"); + + const resumed = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(resumed.action).toBe("submitted"); + expect(resumed.attempt?.taskIndex).toBe(0); + expect(fakeDaemon.submittedPrompts).toHaveLength(2); + }); +}); + +function makeJob(state: DaemonJob["state"], outputText?: string): DaemonJob { + const now = new Date().toISOString(); + return { + id: "job-test", + instanceId: "instance-test", + task: { type: "prompt", prompt: "test" }, + state, + createdAt: now, + updatedAt: now, + outputText, + }; +} diff --git a/apps/tui/src/lib/execution-loop.ts b/apps/tui/src/lib/execution-loop.ts new file mode 100644 index 0000000..27de540 --- /dev/null +++ b/apps/tui/src/lib/execution-loop.ts @@ -0,0 +1,712 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { + DaemonJob, + JobSession, + JobTask, + ManagedInstance, +} from "@techatnyu/ralphd"; +import { z } from "zod"; +import type { PrdTask } from "../hooks/use-plan-files"; +import { parsePrd } from "./plan-validation"; +import type { ProjectStorePaths } from "./project-store"; + +export const TASK_COMPLETE_SENTINEL = "RALPH_TASK_COMPLETE"; + +const LoopStatusSchema = z.enum([ + "idle", + "running", + "paused", + "needs_attention", + "completed", +]); + +const AttemptStatusSchema = z.enum([ + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "needs_attention", + "verified", +]); + +const VerificationSnapshotSchema = z.object({ + progressLength: z.number().int().nonnegative(), + gitRepository: z.boolean(), + gitHead: z.string().nullable(), +}); + +const TaskAttemptSchema = z.object({ + id: z.string().min(1), + taskIndex: z.number().int().nonnegative(), + taskDescription: z.string().min(1), + attemptNumber: z.number().int().positive(), + status: AttemptStatusSchema, + jobId: z.string().min(1).optional(), + sessionId: z.string().min(1).optional(), + submittedAt: z.string().optional(), + updatedAt: z.string(), + verifiedAt: z.string().optional(), + verificationErrors: z.array(z.string()).optional(), + verificationWarnings: z.array(z.string()).optional(), + before: VerificationSnapshotSchema.optional(), +}); + +const LoopStateSchema = z.object({ + version: z.literal(1), + projectRoot: z.string().min(1), + status: LoopStatusSchema, + currentTaskIndex: z.number().int().nonnegative().optional(), + attempts: z.array(TaskAttemptSchema), + lastVerificationFailure: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().optional(), +}); + +export type LoopStatus = z.infer; +export type AttemptStatus = z.infer; +export type VerificationSnapshot = z.infer; +export type TaskAttempt = z.infer; +export type LoopState = z.infer; + +export interface ExecutionDaemon { + listInstances(): Promise<{ instances: ManagedInstance[] }>; + createInstance(params: { + name: string; + directory: string; + maxConcurrency?: number; + }): Promise<{ instance: ManagedInstance }>; + submitJob(params: { + instanceId: string; + session: JobSession; + task: JobTask; + }): Promise<{ job: DaemonJob }>; + getJob(jobId: string): Promise<{ job: DaemonJob }>; +} + +export interface LoopAdvanceResult { + state: LoopState; + action: "submitted" | "monitoring" | "verified" | "paused" | "completed"; + message: string; + job?: DaemonJob; + attempt?: TaskAttempt; +} + +export interface VerifyTaskCompletionOptions { + paths: ProjectStorePaths; + taskIndex: number; + task: PrdTask; + job: DaemonJob; + before?: VerificationSnapshot; + readGitHead?: (projectRoot: string) => Promise; +} + +export interface VerificationResult { + ok: boolean; + errors: string[]; + warnings: string[]; +} + +function isoNow(now: () => Date): string { + return now().toISOString(); +} + +function createLoopState(paths: ProjectStorePaths, now: () => Date): LoopState { + const timestamp = isoNow(now); + return { + version: 1, + projectRoot: paths.projectRoot, + status: "idle", + attempts: [], + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +export async function loadLoopState( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const raw = await readText(paths.loopPath); + if (!raw) { + return createLoopState(paths, now); + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + throw new Error("loop.json: invalid JSON"); + } + + const parsed = LoopStateSchema.safeParse(json); + if (!parsed.success) { + throw new Error( + `loop.json: ${parsed.error.issues[0]?.message ?? "invalid"}`, + ); + } + + return parsed.data; +} + +export async function saveLoopState( + paths: ProjectStorePaths, + state: LoopState, +): Promise { + await writeFile( + paths.loopPath, + `${JSON.stringify(state, null, "\t")}\n`, + "utf8", + ); +} + +export async function readProjectTasks( + paths: ProjectStorePaths, +): Promise { + const prd = await readText(paths.prdPath); + const parsed = parsePrd(prd); + if (parsed.error) { + throw new Error(`prd.json: ${parsed.error}`); + } + return parsed.tasks; +} + +export function findNextPendingTaskIndex(tasks: PrdTask[]): number | null { + const index = tasks.findIndex((task) => !task.passed); + return index === -1 ? null : index; +} + +export function getActiveAttempt(state: LoopState): TaskAttempt | undefined { + return [...state.attempts] + .reverse() + .find((attempt) => + ["queued", "running", "succeeded", "failed", "needs_attention"].includes( + attempt.status, + ), + ); +} + +function getAttemptCount(state: LoopState, taskIndex: number): number { + return state.attempts.filter((attempt) => attempt.taskIndex === taskIndex) + .length; +} + +function isTerminalJob(job: DaemonJob): boolean { + return ( + job.state === "succeeded" || + job.state === "failed" || + job.state === "cancelled" + ); +} + +function jobStateToAttemptStatus(job: DaemonJob): AttemptStatus { + if (job.state === "succeeded") return "succeeded"; + if (job.state === "failed") return "failed"; + if (job.state === "cancelled") return "cancelled"; + if (job.state === "running") return "running"; + return "queued"; +} + +async function runGit( + projectRoot: string, + args: string[], +): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + if (exitCode !== 0) return null; + return stdout.trim(); + } catch { + return null; + } +} + +export async function isGitRepository(projectRoot: string): Promise { + return ( + (await runGit(projectRoot, ["rev-parse", "--is-inside-work-tree"])) === + "true" + ); +} + +export async function readGitHead(projectRoot: string): Promise { + return runGit(projectRoot, ["rev-parse", "HEAD"]); +} + +export async function captureVerificationSnapshot( + paths: ProjectStorePaths, +): Promise { + const progress = (await readText(paths.progressPath)) ?? ""; + const gitRepository = await isGitRepository(paths.projectRoot); + const gitHead = gitRepository ? await readGitHead(paths.projectRoot) : null; + return { + progressLength: progress.length, + gitRepository, + gitHead, + }; +} + +function progressMentionsTask( + progress: string, + taskIndex: number, + task: PrdTask, +): boolean { + const normalized = progress.toLowerCase(); + const description = task.description.trim().toLowerCase(); + const descriptionPrefix = description.slice( + 0, + Math.min(description.length, 80), + ); + return ( + normalized.includes(descriptionPrefix) || + normalized.includes(`task ${taskIndex}`) || + normalized.includes(`task ${taskIndex + 1}`) + ); +} + +export function buildExecutionPrompt({ + paths, + task, + taskIndex, + totalTasks, +}: { + paths: ProjectStorePaths; + task: PrdTask; + taskIndex: number; + totalTasks: number; +}): string { + const lines = [ + "You are Ralph's execution agent for one PRD task attempt.", + "", + "Project:", + `- Project root: ${paths.projectRoot}`, + `- Project store: ${paths.storeDir}`, + `- SPEC.md: ${paths.specPath}`, + `- prd.json: ${paths.prdPath}`, + `- progress.md: ${paths.progressPath}`, + `- loop.json: ${paths.loopPath}`, + "", + "Execution contract:", + `- Implement exactly task index ${taskIndex} (task ${taskIndex + 1} of ${totalTasks}).`, + "- Read SPEC.md, prd.json, and progress.md from the absolute paths above before editing.", + "- Do not work on later tasks unless they are strictly required by this task.", + "- Append a progress.md entry that references this task and summarizes the work.", + `- Set only tasks[${taskIndex}].passed to true in prd.json after the work is complete.`, + "- Preserve valid prd.json JSON with the existing tasks array shape.", + "- If the project root is a git repository, commit the project-root code changes before finishing.", + `- Print ${TASK_COMPLETE_SENTINEL} on its own line only when the task is truly complete.`, + "", + "Task:", + `Description: ${task.description}`, + "", + "Subtasks:", + ...task.subtasks.map((subtask) => `- ${subtask}`), + ]; + + if (task.notes?.trim()) { + lines.push("", `Notes: ${task.notes.trim()}`); + } + + return lines.join("\n"); +} + +export async function verifyTaskCompletion({ + paths, + taskIndex, + task, + job, + before, + readGitHead: readHead = readGitHead, +}: VerifyTaskCompletionOptions): Promise { + const errors: string[] = []; + const warnings: string[] = []; + const missingSentinel = !job.outputText?.includes(TASK_COMPLETE_SENTINEL); + + if (job.state !== "succeeded") { + errors.push(`job ended as ${job.state}`); + } + + let tasks: PrdTask[] = []; + try { + tasks = await readProjectTasks(paths); + } catch (error) { + errors.push( + error instanceof Error ? error.message : "prd.json did not parse", + ); + } + + if (!tasks[taskIndex]?.passed) { + errors.push(`tasks[${taskIndex}].passed is not true`); + } + + const progress = (await readText(paths.progressPath)) ?? ""; + if (before && progress.length <= before.progressLength) { + errors.push("progress.md was not appended"); + } + if (!progressMentionsTask(progress, taskIndex, task)) { + errors.push("progress.md does not reference the task"); + } + + if (before?.gitRepository) { + const afterHead = await readHead(paths.projectRoot); + if (!afterHead) { + errors.push("git HEAD is missing after the attempt"); + } else if (afterHead === before.gitHead) { + errors.push("git HEAD did not advance during the attempt"); + } + } + + if (missingSentinel) { + if (errors.length === 0) { + warnings.push("verified without sentinel"); + } else { + errors.push(`job output is missing ${TASK_COMPLETE_SENTINEL}`); + } + } + + return { + ok: errors.length === 0, + errors, + warnings, + }; +} + +async function ensureProjectInstance( + daemonClient: ExecutionDaemon, + paths: ProjectStorePaths, +): Promise { + const { instances } = await daemonClient.listInstances(); + const existing = instances.find( + (instance) => instance.directory === paths.projectRoot, + ); + if (existing) return existing; + return ( + await daemonClient.createInstance({ + name: paths.slug, + directory: paths.projectRoot, + maxConcurrency: 1, + }) + ).instance; +} + +function updateAttempt( + state: LoopState, + attemptId: string, + update: Partial, +): LoopState { + return { + ...state, + attempts: state.attempts.map((attempt) => + attempt.id === attemptId ? { ...attempt, ...update } : attempt, + ), + }; +} + +export async function advanceExecutionLoop({ + paths, + daemonClient, + now = () => new Date(), +}: { + paths: ProjectStorePaths; + daemonClient: ExecutionDaemon; + now?: () => Date; +}): Promise { + const timestamp = isoNow(now); + let state = await loadLoopState(paths, now); + + if (state.status === "needs_attention") { + return { + state, + action: "paused", + message: state.lastVerificationFailure ?? "Loop needs attention", + }; + } + + const tasks = await readProjectTasks(paths); + const active = getActiveAttempt(state); + + if (active?.jobId) { + const { job } = await daemonClient.getJob(active.jobId); + const status = jobStateToAttemptStatus(job); + state = updateAttempt(state, active.id, { + status, + sessionId: job.sessionId ?? active.sessionId, + updatedAt: timestamp, + }); + + if (!isTerminalJob(job)) { + state = { + ...state, + status: "running", + currentTaskIndex: active.taskIndex, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "monitoring", + message: `Monitoring job ${job.id.slice(0, 8)}`, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + if (job.state !== "succeeded") { + const message = job.error ?? `Job ended as ${job.state}`; + state = updateAttempt(state, active.id, { + status, + verificationErrors: [message], + updatedAt: timestamp, + }); + state = { + ...state, + status: job.state === "cancelled" ? "paused" : "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "paused", + message, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + const task = tasks[active.taskIndex]; + if (!task) { + const message = `Task ${active.taskIndex} no longer exists in prd.json`; + state = updateAttempt(state, active.id, { + status: "needs_attention", + verificationErrors: [message], + updatedAt: timestamp, + }); + state = { + ...state, + status: "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { state, action: "paused", message, job }; + } + + const verification = await verifyTaskCompletion({ + paths, + taskIndex: active.taskIndex, + task, + job, + before: active.before, + }); + if (!verification.ok) { + const message = verification.errors.join("; "); + state = updateAttempt(state, active.id, { + status: "needs_attention", + verificationErrors: verification.errors, + verificationWarnings: verification.warnings, + updatedAt: timestamp, + }); + state = { + ...state, + status: "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "paused", + message, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + state = updateAttempt(state, active.id, { + status: "verified", + verificationErrors: [], + verificationWarnings: verification.warnings, + verifiedAt: timestamp, + updatedAt: timestamp, + }); + state = { + ...state, + status: "running", + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + const warningSuffix = verification.warnings.length + ? ` (${verification.warnings.join(", ")})` + : ""; + return { + state, + action: "verified", + message: `Verified task ${active.taskIndex + 1}${warningSuffix}`, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + const pendingIndex = findNextPendingTaskIndex(tasks); + if (pendingIndex === null) { + state = { + ...state, + status: "completed", + currentTaskIndex: undefined, + completedAt: timestamp, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { state, action: "completed", message: "All tasks completed" }; + } + + const task = tasks[pendingIndex]; + if (!task) { + throw new Error(`Task ${pendingIndex} is missing`); + } + + const instance = await ensureProjectInstance(daemonClient, paths); + const before = await captureVerificationSnapshot(paths); + const prompt = buildExecutionPrompt({ + paths, + task, + taskIndex: pendingIndex, + totalTasks: tasks.length, + }); + const { job } = await daemonClient.submitJob({ + instanceId: instance.id, + session: { + type: "new", + title: `Task ${pendingIndex + 1}: ${task.description.slice(0, 60)}`, + }, + task: { type: "prompt", prompt }, + }); + + const attempt: TaskAttempt = { + id: `${pendingIndex}-${Date.now().toString(36)}`, + taskIndex: pendingIndex, + taskDescription: task.description, + attemptNumber: getAttemptCount(state, pendingIndex) + 1, + status: jobStateToAttemptStatus(job), + jobId: job.id, + sessionId: job.sessionId, + submittedAt: timestamp, + updatedAt: timestamp, + before, + }; + + state = { + ...state, + status: "running", + currentTaskIndex: pendingIndex, + attempts: [...state.attempts, attempt], + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "submitted", + message: `Submitted task ${pendingIndex + 1}`, + job, + attempt, + }; +} + +export async function markActiveAttemptCancelled( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const active = getActiveAttempt(state); + if (!active) { + const next = { + ...state, + status: "paused" as const, + updatedAt: timestamp, + }; + await saveLoopState(paths, next); + return next; + } + const next = updateAttempt(state, active.id, { + status: "cancelled", + updatedAt: timestamp, + verificationErrors: ["Cancelled by user"], + }); + const cancelled = { + ...next, + status: "paused" as const, + lastVerificationFailure: "Cancelled by user", + updatedAt: timestamp, + }; + await saveLoopState(paths, cancelled); + return cancelled; +} + +export async function markLoopPaused( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const paused = { + ...state, + status: "paused" as const, + updatedAt: timestamp, + }; + await saveLoopState(paths, paused); + return paused; +} + +export async function acceptActiveAttempt( + paths: ProjectStorePaths, + options: { + warning?: string; + now?: () => Date; + } = {}, +): Promise { + const now = options.now ?? (() => new Date()); + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const active = getActiveAttempt(state); + if (!active) { + throw new Error("No active attempt to accept"); + } + const warnings = [ + ...(active.verificationWarnings ?? []), + options.warning ?? "manually accepted", + ].filter((warning, index, all) => all.indexOf(warning) === index); + const next = updateAttempt(state, active.id, { + status: "verified", + verificationErrors: [], + verificationWarnings: warnings, + verifiedAt: timestamp, + updatedAt: timestamp, + }); + const accepted = { + ...next, + status: "running" as const, + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, accepted); + return accepted; +} diff --git a/apps/tui/src/lib/plan-artifacts.test.ts b/apps/tui/src/lib/plan-artifacts.test.ts new file mode 100644 index 0000000..d092d5b --- /dev/null +++ b/apps/tui/src/lib/plan-artifacts.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writePrdArtifact, writeSpecArtifact } from "./plan-artifacts"; + +describe("plan artifacts", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempScaffold(): Promise { + const dir = await mkdtemp(join(tmpdir(), "ralph-plan-artifacts-")); + tempDirs.push(dir); + return dir; + } + + it("writes valid SPEC markdown", async () => { + const scaffold = await tempScaffold(); + const spec = `# Todo App + +> A small task manager for personal use. + +## Overview +Build a browser todo app that lets one person add, complete, and delete tasks while keeping state locally. + +## Scope +- Add tasks +- Complete tasks +- Delete tasks +`; + + await writeSpecArtifact(scaffold, spec); + + expect(await readFile(join(scaffold, "SPEC.md"), "utf8")).toBe( + `${spec.trim()}\n`, + ); + }); + + it("rejects too-short SPEC markdown", async () => { + const scaffold = await tempScaffold(); + + await expect(writeSpecArtifact(scaffold, "# Tiny\n")).rejects.toThrow( + "too short", + ); + }); + + it("rejects fake tool calls for SPEC output", async () => { + const scaffold = await tempScaffold(); + + await expect( + writeSpecArtifact(scaffold, 'write("SPEC.md", "# Todo")'), + ).rejects.toThrow("printed a tool call"); + }); + + it("accepts a single fenced markdown SPEC block", async () => { + const scaffold = await tempScaffold(); + const spec = `# Todo App + +> A small task manager for personal use. + +## Overview +Build a browser todo app that lets one person add, complete, and delete tasks while keeping state locally. + +## Scope +- Add tasks +- Complete tasks +- Delete tasks`; + + await writeSpecArtifact(scaffold, ["```markdown", spec, "```"].join("\n")); + + expect(await readFile(join(scaffold, "SPEC.md"), "utf8")).toBe(`${spec}\n`); + }); + + it("writes valid PRD JSON and normalizes formatting", async () => { + const scaffold = await tempScaffold(); + const prd = `{"tasks":[{"description":"Build task storage","subtasks":["Create storage helper","Run tests"]}]}`; + + await writePrdArtifact(scaffold, prd); + + const written = await readFile(join(scaffold, "prd.json"), "utf8"); + expect(written).toContain('\n\t"tasks"'); + expect(JSON.parse(written)).toEqual({ + tasks: [ + { + description: "Build task storage", + subtasks: ["Create storage helper", "Run tests"], + notes: "", + passed: false, + }, + ], + }); + }); + + it("accepts a single fenced json PRD block", async () => { + const scaffold = await tempScaffold(); + + await writePrdArtifact( + scaffold, + [ + "```json", + '{"tasks":[{"description":"Create UI","subtasks":["Add form","Run typecheck"],"notes":"Keep it simple","passed":false}]}', + "```", + ].join("\n"), + ); + + const written = await readFile(join(scaffold, "prd.json"), "utf8"); + expect(JSON.parse(written).tasks).toHaveLength(1); + }); + + it("rejects prose-wrapped PRD JSON", async () => { + const scaffold = await tempScaffold(); + + await expect( + writePrdArtifact( + scaffold, + `Here is the JSON: +{"tasks":[{"description":"Create UI","subtasks":["Run tests"]}]}`, + ), + ).rejects.toThrow("raw JSON or a single fenced json block"); + }); + + it("rejects schema-invalid PRD JSON", async () => { + const scaffold = await tempScaffold(); + + await expect(writePrdArtifact(scaffold, `{"tasks":[]}`)).rejects.toThrow( + "tasks", + ); + }); +}); diff --git a/apps/tui/src/lib/plan-artifacts.ts b/apps/tui/src/lib/plan-artifacts.ts new file mode 100644 index 0000000..54f53ee --- /dev/null +++ b/apps/tui/src/lib/plan-artifacts.ts @@ -0,0 +1,67 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { parsePrd, validateSpec } from "./plan-validation"; + +function normalizeModelOutput(content: string): string { + const trimmed = content.trim(); + if (!trimmed) { + throw new Error("empty response"); + } + if (/ { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +export async function writeSpecArtifact( + scaffoldPath: string, + content: string, +): Promise { + const spec = extractSpecMarkdown(content); + const validation = validateSpec(spec); + if (!validation.valid) { + throw new Error(validation.error ?? "invalid SPEC.md"); + } + await writeArtifact(join(scaffoldPath, "SPEC.md"), `${spec}\n`); +} + +export async function writePrdArtifact( + scaffoldPath: string, + content: string, +): Promise { + const jsonText = extractPrdJson(content); + const parsed = parsePrd(jsonText); + if (parsed.error) { + throw new Error(parsed.error); + } + await writeArtifact( + join(scaffoldPath, "prd.json"), + `${JSON.stringify({ tasks: parsed.tasks }, null, "\t")}\n`, + ); +} diff --git a/apps/tui/src/lib/plan-validation.ts b/apps/tui/src/lib/plan-validation.ts new file mode 100644 index 0000000..54b743b --- /dev/null +++ b/apps/tui/src/lib/plan-validation.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const PrdTaskSchema = z.object({ + description: z.string().min(1), + subtasks: z.array(z.string().min(1)).min(1), + notes: z.string().optional().default(""), + passed: z.boolean().optional().default(false), +}); + +export const PrdFileSchema = z.object({ + tasks: z.array(PrdTaskSchema).min(1), +}); + +export type PrdTask = z.infer; +export type PrdFile = z.infer; + +export interface PrdParseResult { + tasks: PrdTask[]; + error?: string; +} + +export function parsePrd(content: string | null): PrdParseResult { + if (content === null) return { tasks: [] }; + let json: unknown; + try { + json = JSON.parse(content); + } catch { + return { tasks: [], error: "invalid JSON" }; + } + const parsed = PrdFileSchema.safeParse(json); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const path = first?.path.join(".") || "root"; + const message = first?.message ?? "validation failed"; + return { tasks: [], error: `${path}: ${message}` }; + } + return { tasks: parsed.data.tasks }; +} + +export interface SpecValidation { + valid: boolean; + error?: string; +} + +export function validateSpec(content: string | null): SpecValidation { + if (content === null) return { valid: false }; + const trimmed = content.trim(); + if (trimmed.length < 100) { + return { valid: false, error: "too short (<100 chars)" }; + } + if (!/^#\s+\S/m.test(trimmed)) { + return { valid: false, error: "missing markdown heading" }; + } + return { valid: true }; +} diff --git a/apps/tui/src/lib/project-store.test.ts b/apps/tui/src/lib/project-store.test.ts new file mode 100644 index 0000000..a9961e4 --- /dev/null +++ b/apps/tui/src/lib/project-store.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + createProjectSlug, + ensureProjectStore, + resolveProjectRoot, + resolveProjectStore, +} from "./project-store"; + +const VALID_SPEC = `# Todo App + +This specification is intentionally long enough for the validator. It describes a small todo app with a task form, a task list, completion state, deletion, and simple persistent behavior for Ralph project-store migration tests. +`; + +describe("project store", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + it("resolves the project root by walking up to package.json", async () => { + const root = await tempDir("ralph-project-root-"); + const nested = join(root, "apps", "tui", "src"); + await mkdir(nested, { recursive: true }); + await writeFile(join(root, "package.json"), "{}", "utf8"); + + await expect(resolveProjectRoot(nested)).resolves.toBe(root); + }); + + it("falls back to cwd when no root marker exists", async () => { + const root = await tempDir("ralph-project-fallback-"); + const nested = join(root, "loose"); + await mkdir(nested, { recursive: true }); + + await expect(resolveProjectRoot(nested)).resolves.toBe(resolve(nested)); + }); + + it("builds a stable sanitized slug with a short root hash", () => { + const root = "/tmp/My Demo App!"; + const first = createProjectSlug(root); + const second = createProjectSlug(root); + + expect(first).toBe(second); + expect(first).toMatch(/^my-demo-app-[a-f0-9]{8}$/); + }); + + it("creates metadata and preserves createdAt across updates", async () => { + const projectRoot = await tempDir("ralph-project-metadata-"); + const ralphHome = await tempDir("ralph-home-metadata-"); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + + const first = await ensureProjectStore({ + projectRoot, + ralphHome, + now: () => new Date("2026-01-01T00:00:00.000Z"), + }); + const second = await ensureProjectStore({ + projectRoot, + ralphHome, + now: () => new Date("2026-01-02T00:00:00.000Z"), + }); + + expect(second.storeDir).toBe(first.storeDir); + const metadata = JSON.parse(await readFile(first.metadataPath, "utf8")); + expect(metadata.projectRoot).toBe(projectRoot); + expect(metadata.slug).toBe(createProjectSlug(projectRoot)); + expect(metadata.createdAt).toBe("2026-01-01T00:00:00.000Z"); + expect(metadata.updatedAt).toBe("2026-01-02T00:00:00.000Z"); + }); + + it("migrates valid legacy plan files without overwriting project-store files", async () => { + const projectRoot = await tempDir("ralph-project-migrate-"); + const ralphHome = await tempDir("ralph-home-migrate-"); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + + const legacyDir = join(ralphHome, "sessions", "instance-1", "plan"); + await mkdir(legacyDir, { recursive: true }); + await writeFile(join(legacyDir, "SPEC.md"), VALID_SPEC, "utf8"); + await writeFile( + join(legacyDir, "prd.json"), + JSON.stringify({ + tasks: [ + { + description: "Build the shell", + subtasks: ["Create index.html"], + passed: false, + }, + ], + }), + "utf8", + ); + await writeFile( + join(legacyDir, "progress.md"), + "# Progress Log\n\nLegacy", + "utf8", + ); + + const paths = await ensureProjectStore({ + projectRoot, + ralphHome, + legacyInstanceId: "instance-1", + }); + expect(await readFile(paths.specPath, "utf8")).toBe(VALID_SPEC); + expect(await readFile(paths.progressPath, "utf8")).toContain("Legacy"); + + await writeFile( + join(legacyDir, "SPEC.md"), + `${VALID_SPEC}\nchanged`, + "utf8", + ); + await ensureProjectStore({ + projectRoot, + ralphHome, + legacyInstanceId: "instance-1", + }); + expect(await readFile(paths.specPath, "utf8")).toBe(VALID_SPEC); + }); + + it("places canonical files under RALPH_HOME/projects/", async () => { + const projectRoot = await tempDir("ralph-project-paths-"); + const ralphHome = await tempDir("ralph-home-paths-"); + const paths = await resolveProjectStore({ projectRoot, ralphHome }); + + expect(paths.storeDir).toBe( + join(ralphHome, "projects", createProjectSlug(projectRoot)), + ); + expect(paths.prdPath).toBe(join(paths.storeDir, "prd.json")); + expect(paths.loopPath).toBe(join(paths.storeDir, "loop.json")); + }); +}); diff --git a/apps/tui/src/lib/project-store.ts b/apps/tui/src/lib/project-store.ts new file mode 100644 index 0000000..c70ccef --- /dev/null +++ b/apps/tui/src/lib/project-store.ts @@ -0,0 +1,275 @@ +import { createHash } from "node:crypto"; +import { + access, + copyFile, + mkdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { resolveDaemonPaths } from "@techatnyu/ralphd"; +import { parsePrd, validateSpec } from "./plan-validation"; + +export interface ProjectStorePaths { + projectRoot: string; + slug: string; + storeDir: string; + specPath: string; + prdPath: string; + progressPath: string; + promptPath: string; + loopPath: string; + metadataPath: string; +} + +export interface ProjectStoreMetadata { + version: 1; + projectRoot: string; + projectName: string; + slug: string; + createdAt: string; + updatedAt: string; +} + +export interface ResolveProjectStoreOptions { + projectRoot?: string; + cwd?: string; + ralphHome?: string; +} + +export interface EnsureProjectStoreOptions extends ResolveProjectStoreOptions { + legacyInstanceId?: string; + now?: () => Date; +} + +const PROJECT_FILES = { + spec: "SPEC.md", + prd: "prd.json", + progress: "progress.md", + prompt: "PROMPT.md", + loop: "loop.json", + metadata: "metadata.json", +} as const; + +function getRalphHome(ralphHome?: string): string { + return resolve(ralphHome ?? resolveDaemonPaths().ralphHome); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function hasRootMarker(path: string): Promise { + const [git, packageJson] = await Promise.all([ + stat(join(path, ".git")) + .then(() => true) + .catch(() => false), + stat(join(path, "package.json")) + .then((info) => info.isFile()) + .catch(() => false), + ]); + return git || packageJson; +} + +export async function resolveProjectRoot(cwd = process.cwd()): Promise { + let current = resolve(cwd); + + while (true) { + if (await hasRootMarker(current)) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + return resolve(cwd); + } + current = parent; + } +} + +export function createProjectSlug(projectRoot: string): string { + const root = resolve(projectRoot); + const name = basename(root) || "project"; + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const hash = createHash("sha1").update(root).digest("hex").slice(0, 8); + return `${sanitized || "project"}-${hash}`; +} + +export function buildProjectStorePaths( + projectRoot: string, + ralphHome?: string, +): ProjectStorePaths { + const normalizedRoot = resolve(projectRoot); + const slug = createProjectSlug(normalizedRoot); + const storeDir = join(getRalphHome(ralphHome), "projects", slug); + + return { + projectRoot: normalizedRoot, + slug, + storeDir, + specPath: join(storeDir, PROJECT_FILES.spec), + prdPath: join(storeDir, PROJECT_FILES.prd), + progressPath: join(storeDir, PROJECT_FILES.progress), + promptPath: join(storeDir, PROJECT_FILES.prompt), + loopPath: join(storeDir, PROJECT_FILES.loop), + metadataPath: join(storeDir, PROJECT_FILES.metadata), + }; +} + +export async function resolveProjectStore( + options: ResolveProjectStoreOptions = {}, +): Promise { + const projectRoot = + options.projectRoot ?? + (await resolveProjectRoot(options.cwd ?? process.cwd())); + return buildProjectStorePaths(projectRoot, options.ralphHome); +} + +function buildProjectPrompt(paths: ProjectStorePaths): string { + return `# Ralph Project Store + +This directory is Ralph's canonical project store for: + +- Project root: ${paths.projectRoot} +- SPEC.md: ${paths.specPath} +- prd.json: ${paths.prdPath} +- progress.md: ${paths.progressPath} +- loop.json: ${paths.loopPath} + +Execution agents must update prd.json and progress.md here, then commit project-root changes when the project is a git repository. Finish successful task attempts by printing exactly: + +RALPH_TASK_COMPLETE +`; +} + +async function writeProjectPromptIfMissing( + paths: ProjectStorePaths, +): Promise { + if (await pathExists(paths.promptPath)) return; + await writeFile(paths.promptPath, buildProjectPrompt(paths), "utf8"); +} + +async function copyValidLegacyFile( + legacyPath: string, + targetPath: string, + validate: (content: string) => boolean, +): Promise { + if (await pathExists(targetPath)) return; + if (!(await pathExists(legacyPath))) return; + + const content = await readFile(legacyPath, "utf8"); + if (!validate(content)) return; + + await copyFile(legacyPath, targetPath); +} + +async function migrateLegacyPlanFiles( + paths: ProjectStorePaths, + legacyInstanceId: string | undefined, + ralphHome?: string, +): Promise { + if (!legacyInstanceId) return; + + const legacyDir = join( + getRalphHome(ralphHome), + "sessions", + legacyInstanceId, + "plan", + ); + await Promise.all([ + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.spec), + paths.specPath, + (content) => validateSpec(content).valid, + ), + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.prd), + paths.prdPath, + (content) => !parsePrd(content).error, + ), + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.progress), + paths.progressPath, + (content) => content.trim().length > 0, + ), + ]); +} + +async function readMetadata( + paths: ProjectStorePaths, +): Promise { + try { + const raw = await readFile(paths.metadataPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.updatedAt !== "string" + ) { + return null; + } + return { + version: 1, + projectRoot: paths.projectRoot, + projectName: basename(paths.projectRoot), + slug: paths.slug, + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + }; + } catch { + return null; + } +} + +async function writeMetadata( + paths: ProjectStorePaths, + now: () => Date, +): Promise { + const timestamp = now().toISOString(); + const existing = await readMetadata(paths); + const metadata: ProjectStoreMetadata = { + version: 1, + projectRoot: paths.projectRoot, + projectName: basename(paths.projectRoot), + slug: paths.slug, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + await writeFile( + paths.metadataPath, + `${JSON.stringify(metadata, null, "\t")}\n`, + "utf8", + ); + return metadata; +} + +export async function ensureProjectStore( + options: EnsureProjectStoreOptions = {}, +): Promise { + const paths = await resolveProjectStore(options); + const now = options.now ?? (() => new Date()); + + await mkdir(paths.storeDir, { recursive: true }); + await migrateLegacyPlanFiles( + paths, + options.legacyInstanceId, + options.ralphHome, + ); + await writeProjectPromptIfMissing(paths); + + if (!(await pathExists(paths.progressPath))) { + await writeFile(paths.progressPath, "# Progress Log\n", "utf8"); + } + + await writeMetadata(paths, now); + return paths; +} diff --git a/apps/tui/src/lib/scaffold.test.ts b/apps/tui/src/lib/scaffold.test.ts index ca8cdb4..c588a89 100644 --- a/apps/tui/src/lib/scaffold.test.ts +++ b/apps/tui/src/lib/scaffold.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + bootstrapInstanceScaffold, bootstrapSessionScaffold, + resolveInstanceScaffoldPath, resolveSessionScaffoldPath, } from "./scaffold"; @@ -100,4 +102,49 @@ describe("scaffold", () => { expect(JSON.parse(prd)).toEqual({ tasks: [] }); expect(progress.trim()).toBe("# Progress Log"); }); + + it("resolves the instance scaffold path under sessions//plan", () => { + expect( + resolveInstanceScaffoldPath({ + instanceId: "instance-9", + ralphHome: "/tmp/ralph-home", + }), + ).toBe("/tmp/ralph-home/sessions/instance-9/plan"); + }); + + it("rejects invalid instance ids", () => { + expect(() => + resolveInstanceScaffoldPath({ + instanceId: "", + ralphHome: "/tmp/ralph-home", + }), + ).toThrow("instanceId is required"); + + expect(() => + resolveInstanceScaffoldPath({ + instanceId: "a/b", + ralphHome: "/tmp/ralph-home", + }), + ).toThrow("instanceId must not contain path separators"); + }); + + it("creates the instance scaffold directory idempotently", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-instance-scaffold-")); + tempDirs.push(tempHome); + + const first = await bootstrapInstanceScaffold({ + instanceId: "instance-10", + ralphHome: tempHome, + }); + expect(first).toBe(join(tempHome, "sessions", "instance-10", "plan")); + + const info = await stat(first); + expect(info.isDirectory()).toBe(true); + + const second = await bootstrapInstanceScaffold({ + instanceId: "instance-10", + ralphHome: tempHome, + }); + expect(second).toBe(first); + }); }); diff --git a/apps/tui/src/lib/scaffold.ts b/apps/tui/src/lib/scaffold.ts index 9a4004f..5787f6a 100644 --- a/apps/tui/src/lib/scaffold.ts +++ b/apps/tui/src/lib/scaffold.ts @@ -53,3 +53,27 @@ export async function bootstrapSessionScaffold( return sessionPath; } + +export interface InstanceScaffoldOptions { + instanceId: string; + ralphHome?: string; +} + +export function resolveInstanceScaffoldPath( + options: InstanceScaffoldOptions, +): string { + assertValidSegment(options.instanceId, "instanceId"); + + const ralphHome = + options.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; + + return join(ralphHome, "sessions", options.instanceId, "plan"); +} + +export async function bootstrapInstanceScaffold( + options: InstanceScaffoldOptions, +): Promise { + const scaffoldPath = resolveInstanceScaffoldPath(options); + await mkdir(scaffoldPath, { recursive: true }); + return scaffoldPath; +} diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts new file mode 100644 index 0000000..37e1c98 --- /dev/null +++ b/apps/tui/src/skills.ts @@ -0,0 +1,146 @@ +import type { PermissionRule } from "@techatnyu/ralphd"; + +export interface SkillContext { + scaffoldPath: string; +} + +export type SkillId = "brainstorm" | "spec" | "prd"; +export type ActiveSkill = SkillId | null; + +export interface Skill { + id: SkillId; + name: string; + inputPlaceholder: string; + buildSystemPrompt: (ctx: SkillContext) => string; + buildPermission: (ctx: SkillContext) => PermissionRule[]; + buildAutoPrompt?: (ctx: SkillContext) => string; +} + +function buildPlanChatPermissions(_ctx: SkillContext): PermissionRule[] { + return [ + { permission: "question", pattern: "*", action: "deny" }, + { permission: "*", pattern: "*", action: "deny" }, + ]; +} + +export const BRAINSTORM_SKILL: Skill = { + id: "brainstorm", + name: "Plan", + inputPlaceholder: "What do you want to build?", + buildPermission: buildPlanChatPermissions, + buildSystemPrompt: () => `You are a project planning partner. + +RULES: +- Help the user refine what they want to build through natural conversation. +- Ask concise clarifying questions when useful. +- Offer concrete product, scope, architecture, and implementation tradeoffs. +- Do NOT create or modify files. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. +- When the idea is clear enough, mention that the user can type \`/spec\` to generate the project spec.`, +}; + +export const SPEC_SKILL: Skill = { + id: "spec", + name: "Spec", + inputPlaceholder: "Describe your project...", + buildPermission: buildPlanChatPermissions, + buildSystemPrompt: ( + ctx, + ) => `You are a spec writer. Your ONLY job is to produce the final contents for \`SPEC.md\`. + +RULES: +- Return ONLY the markdown content for \`SPEC.md\`. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. +- Use the conversation context above. Do NOT ask more questions — generate immediately. +- Do NOT include commentary before or after the markdown. + +TARGET FILE (absolute path): +${ctx.scaffoldPath}/SPEC.md + +SPEC.md TEMPLATE: +# Project Name +> One-line description. + +## Overview +[What you're building, the problem it solves, who it's for] + +## Scope +### Included +- [High-level capability 1] +### Excluded +- [What this project will NOT do] + +## Technical Stack +- **Language**: [e.g., TypeScript 5.x] +- **Framework**: [e.g., Next.js 14] +- **Database**: [e.g., PostgreSQL with Prisma] +- **Testing**: [e.g., Vitest] + +## Architecture +[High-level patterns, how major components communicate] + +## Constraints +- [Non-functional requirements that guide decisions]`, + buildAutoPrompt: () => + "Based on our conversation, generate final SPEC.md. Return markdown only.", +}; + +export const PRD_SKILL: Skill = { + id: "prd", + name: "PRD", + inputPlaceholder: "Refine the task breakdown...", + buildPermission: buildPlanChatPermissions, + buildAutoPrompt: (ctx) => + `Create a task breakdown for ${ctx.scaffoldPath}/prd.json from the SPEC.md content below.`, + buildSystemPrompt: ( + ctx, + ) => `You are a task planner. Your ONLY job is to produce the final JSON contents for \`prd.json\`. + +RULES: +- Return ONLY raw JSON matching the schema below. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. +- Use the SPEC.md content supplied in the user prompt. +- Each task must be completable in a single agent session (~1-2 hours). +- Every task MUST end with verification subtasks (tests, typecheck, lint). +- No overlapping scope between tasks. +- Order: setup → models → features → polish → integration tests. +- Do NOT include commentary before or after the JSON. + +INPUT FILE (absolute path): +${ctx.scaffoldPath}/SPEC.md + +TARGET FILE (absolute path): +${ctx.scaffoldPath}/prd.json + +OUTPUT SCHEMA: +\`\`\`json +{ + "tasks": [ + { + "description": "Clear end-goal of the task", + "subtasks": ["Specific step 1", "Specific step 2", "Run tests", "Run typecheck", "Run lint"], + "notes": "Context, constraints, references", + "passed": false + } + ] +} +\`\`\` + +TASK SIZING: +- GOOD: "Implement POST /api/auth/register endpoint" +- BAD: "Build the authentication system" → split into 4-6 tasks + +SUBTASK SPECIFICITY: +- GOOD: "Create src/models/user.ts with User interface, fields: id (UUID), email, passwordHash, createdAt" +- BAD: "Create user model"`, +}; + +const SKILLS: Record = { + brainstorm: BRAINSTORM_SKILL, + spec: SPEC_SKILL, + prd: PRD_SKILL, +}; + +export function getSkill(id: SkillId): Skill | undefined { + return SKILLS[id]; +} diff --git a/bun.lock b/bun.lock index 07d0f03..29674c4 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@opentui/react": "^0.1.77", "@techatnyu/ralphd": "workspace:*", "react": "^19.2.4", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "latest", @@ -69,7 +70,7 @@ "name": "@techatnyu/ralphd", "version": "0.0.0", "dependencies": { - "@opencode-ai/sdk": "^1.2.10", + "@opencode-ai/sdk": "1.14.22", "zod": "^4.3.6", }, "devDependencies": { @@ -346,7 +347,7 @@ "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.13", "", {}, "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.22", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1PjkrZRAwm9ocfTwOleP/e31HYtLVODb2E1hYTRHMmvF2rmAdCm7lztguYVkAPn/B6koGpFvhslTQH7j+38Fjw=="], "@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="], @@ -602,7 +603,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -684,7 +685,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], diff --git a/packages/daemon/package.json b/packages/daemon/package.json index d9cec2f..8d93c84 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -12,7 +12,7 @@ "check:types": "tsc --noEmit" }, "dependencies": { - "@opencode-ai/sdk": "^1.2.10", + "@opencode-ai/sdk": "1.14.22", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/daemon/src/client.ts b/packages/daemon/src/client.ts index d718ba3..b4daf03 100644 --- a/packages/daemon/src/client.ts +++ b/packages/daemon/src/client.ts @@ -239,6 +239,75 @@ export class DaemonClient { >; } + /** + * Submit a job and stream events on the same socket. Eliminates the + * race between separate submit + stream calls where deltas could be + * lost between the two connections. + */ + async submitAndStreamJob( + params: ParamsByMethod<"job.submit_and_stream">, + ): Promise<{ + job: ResultByMethod<"job.submit_and_stream">["job"]; + events: AsyncGenerator; + }> { + const request = RequestMessageSchema.parse({ + id: Bun.randomUUIDv7(), + method: "job.submit_and_stream", + params, + }); + + const socket = connect(this.socketPath); + socket.setEncoding("utf8"); + socket.on("error", () => {}); + const rl = createInterface({ input: socket }); + const lines = rl[Symbol.asyncIterator](); + + socket.write(`${JSON.stringify(request)}\n`); + + const firstLine = await lines.next(); + if (firstLine.done || !firstLine.value.trim()) { + rl.close(); + socket.destroy(); + throw new Error("daemon closed connection before ack"); + } + + const ack = ResponseMessageSchema.safeParse(JSON.parse(firstLine.value)); + if (!ack.success || !ack.data.ok) { + rl.close(); + socket.destroy(); + throw new Error( + !ack.success + ? "invalid daemon response" + : (ack.data as { error: { message: string } }).error.message, + ); + } + + const job = ( + ack.data.result as { job: ResultByMethod<"job.submit_and_stream">["job"] } + ).job; + + async function* eventStream(): AsyncGenerator { + try { + for await (const line of rl) { + if (!line.trim()) continue; + const event = JobStreamEventSchema.safeParse( + JSON.parse(line) as unknown, + ); + if (!event.success) continue; + yield event.data; + if (event.data.type === "done" || event.data.type === "error") { + return; + } + } + } finally { + rl.close(); + socket.destroy(); + } + } + + return { job, events: eventStream() }; + } + /** * Open a stream over the daemon socket and yield job events as they * arrive. The first line on the wire is the ack response (a normal diff --git a/packages/daemon/src/opencode.ts b/packages/daemon/src/opencode.ts index f935665..e6e2511 100644 --- a/packages/daemon/src/opencode.ts +++ b/packages/daemon/src/opencode.ts @@ -1,3 +1,4 @@ +import { createServer } from "node:net"; import { type AssistantMessage, createOpencode, @@ -8,7 +9,15 @@ import { } from "@opencode-ai/sdk/v2"; export interface OpencodeSessionClient { - create(parameters: { directory?: string; title?: string }): Promise; + create(parameters: { + directory?: string; + title?: string; + permission?: Array<{ + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; + }>; + }): Promise; prompt(parameters: { sessionID: string; directory?: string; @@ -20,7 +29,7 @@ export interface OpencodeSessionClient { system?: string; variant?: string; parts?: Array; - }): Promise<{ info: AssistantMessage; parts: Part[] }>; + }): Promise<{ info?: AssistantMessage; parts?: Part[] }>; abort(parameters: { sessionID: string; directory?: string; @@ -47,6 +56,14 @@ export interface ProviderListResult { connected: string[]; } +type RawProviderModel = ProviderModel & { + capabilities?: { + attachment?: boolean; + reasoning?: boolean; + toolcall?: boolean; + }; +}; + export interface OpencodeRuntimeClient { session: OpencodeSessionClient; question: { @@ -108,6 +125,22 @@ interface InstanceSubscription { cancel(): void; } +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (typeof addr === "object" && addr) { + const port = addr.port; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Failed to get port"))); + } + }); + srv.on("error", reject); + }); +} + export class OpencodeRegistry implements OpencodeRuntimeManager { private shared?: SharedRuntime; private sharedStarting?: Promise; @@ -167,8 +200,8 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { responseStyle: "data", }); return res as unknown as { - info: AssistantMessage; - parts: Part[]; + info?: AssistantMessage; + parts?: Part[]; }; }, abort: (parameters) => @@ -192,17 +225,21 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { id: p.id, name: p.name, models: Object.fromEntries( - Object.entries(p.models).map(([k, m]) => [ - k, - { - id: m.id, - name: m.name, - family: m.family, - attachment: m.attachment, - reasoning: m.reasoning, - tool_call: m.tool_call, - }, - ]), + Object.entries(p.models).map(([k, rawModel]) => { + const m = rawModel as RawProviderModel; + return [ + k, + { + id: m.id, + name: m.name, + family: m.family, + attachment: + m.attachment ?? m.capabilities?.attachment, + reasoning: m.reasoning ?? m.capabilities?.reasoning, + tool_call: m.tool_call ?? m.capabilities?.toolcall, + }, + ]; + }), ), })), connected: response.data.connected, diff --git a/packages/daemon/src/protocol.ts b/packages/daemon/src/protocol.ts index a50afce..3b36866 100644 --- a/packages/daemon/src/protocol.ts +++ b/packages/daemon/src/protocol.ts @@ -46,11 +46,19 @@ const JobTask = z.discriminatedUnion("type", [ ]); export type JobTask = z.infer; +const PermissionRule = z.strictObject({ + permission: z.string().min(1), + pattern: z.string().min(1), + action: z.enum(["allow", "deny", "ask"]), +}); +export type PermissionRule = z.infer; + /** Whether the job starts a new conversation or continues an existing one. */ const JobSession = z.discriminatedUnion("type", [ z.strictObject({ type: z.literal("new"), title: z.string().min(1).optional(), + permission: z.array(PermissionRule).optional(), }), z.strictObject({ type: z.literal("existing"), @@ -417,6 +425,7 @@ const RequestMethod = z.enum([ "job.get", "job.cancel", "job.stream", + "job.submit_and_stream", "question.reply", ]); export type RequestMethod = z.infer; @@ -527,6 +536,12 @@ const JobStreamRequest = z.strictObject({ params: JobStreamParams, }); +const JobSubmitAndStreamRequest = z.strictObject({ + id: z.string().min(1), + method: z.literal("job.submit_and_stream"), + params: JobSubmitParams, +}); + const QuestionReplyRequest = z.strictObject({ id: z.string().min(1), method: z.literal("question.reply"), @@ -551,6 +566,7 @@ export const RequestMessage = z.discriminatedUnion("method", [ JobGetRequest, JobCancelRequest, JobStreamRequest, + JobSubmitAndStreamRequest, QuestionReplyRequest, ]); export type RequestMessage = z.infer; @@ -681,6 +697,13 @@ const JobStreamSuccess = z.strictObject({ result: StreamAckResult, }); +const JobSubmitAndStreamSuccess = z.strictObject({ + id: z.string().min(1), + method: z.literal("job.submit_and_stream"), + ok: z.literal(true), + result: SubmitResult, +}); + const QuestionReplySuccess = z.strictObject({ id: z.string().min(1), method: z.literal("question.reply"), @@ -716,6 +739,7 @@ export const ResponseMessage = z.union([ JobGetSuccess, JobCancelSuccess, JobStreamSuccess, + JobSubmitAndStreamSuccess, QuestionReplySuccess, ErrorResponse, ]); diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index d67a7a7..71ba0b1 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -137,6 +137,55 @@ function normalizeErrorMessage(error: unknown): string { return "Unknown daemon error"; } +function normalizeSessionError(error: unknown): string { + if (!error) { + return "OpenCode session error"; + } + + if (typeof error === "string") { + return error; + } + + if (typeof error !== "object") { + return String(error); + } + + const record = error as { + name?: unknown; + data?: unknown; + message?: unknown; + }; + const name = typeof record.name === "string" ? record.name : "OpenCodeError"; + const data = record.data; + + if (typeof data === "object" && data !== null) { + const dataRecord = data as { + message?: unknown; + providerID?: unknown; + modelID?: unknown; + }; + if (typeof dataRecord.message === "string") { + return `${name}: ${dataRecord.message}`; + } + if ( + typeof dataRecord.providerID === "string" && + typeof dataRecord.modelID === "string" + ) { + return `${name}: ${dataRecord.providerID}/${dataRecord.modelID}`; + } + } + + if (typeof record.message === "string") { + return `${name}: ${record.message}`; + } + + try { + return `${name}: ${JSON.stringify(data ?? error)}`; + } catch { + return name; + } +} + export class Daemon { private readonly registry: OpencodeRuntimeManager; /** Per-instance queue of job ids waiting to be scheduled. */ @@ -159,6 +208,16 @@ export class Daemon { private instanceCursor = 0; private readonly maxConcurrency: number; private readonly cancelWaitTimeoutMs: number; + private readonly sessionIdleWaiters = new Map void>(); + private readonly sessionErrors = new Map(); + private readonly pendingPermissions = new Map< + string, + Array<{ + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; + }> + >(); constructor( private readonly store: StateStore, @@ -174,6 +233,22 @@ export class Daemon { event.properties.field, event.properties.delta, ); + } else if (event.type === "session.idle") { + this.resolveSessionIdle(event.properties.sessionID); + } else if ( + event.type === "session.status" && + event.properties.status.type === "idle" + ) { + this.resolveSessionIdle(event.properties.sessionID); + } else if (event.type === "session.error") { + const sessionId = event.properties.sessionID; + if (sessionId) { + this.sessionErrors.set( + sessionId, + normalizeSessionError(event.properties.error), + ); + this.resolveSessionIdle(sessionId); + } } else if (event.type === "question.asked") { this.routeQuestionToJob(instanceId, { requestId: event.properties.id, @@ -245,6 +320,8 @@ export class Daemon { return this.success(raw, await this.handleJobCancel(raw)); case "job.stream": return this.success(raw, this.handleJobStream(raw)); + case "job.submit_and_stream": + return this.success(raw, await this.handleJobSubmitAndStream(raw)); case "question.reply": return this.success(raw, await this.handleQuestionReply(raw)); } @@ -391,6 +468,38 @@ export class Daemon { session: request.params.session, task: request.params.task, }); + if ( + request.params.session.type === "new" && + request.params.session.permission + ) { + this.pendingPermissions.set(job.id, request.params.session.permission); + } + this.enqueueById(instanceId, job.id); + this.scheduleDrain(); + return { job }; + } + + private async handleJobSubmitAndStream( + request: RequestByMethod<"job.submit_and_stream">, + ): Promise { + if (this.shuttingDown) { + throw new StoreError("shutdown", "daemon is shutting down"); + } + + const { instanceId } = request.params; + this.store.assertInstance(instanceId); + + const job = this.store.createJob({ + instanceId, + session: request.params.session, + task: request.params.task, + }); + if ( + request.params.session.type === "new" && + request.params.session.permission + ) { + this.pendingPermissions.set(job.id, request.params.session.permission); + } this.enqueueById(instanceId, job.id); this.scheduleDrain(); return { job }; @@ -555,6 +664,14 @@ export class Daemon { } } + private resolveSessionIdle(sessionId: string): void { + const resolve = this.sessionIdleWaiters.get(sessionId); + if (resolve) { + this.sessionIdleWaiters.delete(sessionId); + resolve(); + } + } + /** * Route an incoming delta from the OpenCode event stream to the matching * running job. Synchronously appends the delta to the job's `output_text` @@ -629,7 +746,7 @@ export class Daemon { } } - private scheduleDrain(): void { + scheduleDrain(): void { if (this.drainPromise) { this.drainPending = true; return; @@ -711,57 +828,119 @@ export class Daemon { >; const patch: { error?: string; outputText?: string; messageId?: string } = {}; + const log = (msg: string) => + process.stdout.write(`\n[job:${job.id.slice(0, 8)}] ${msg}\n`); try { + log("starting instance"); const instance = await this.startInstance(job.instanceId); + log("ensuring runtime"); const runtime = await this.registry.ensureStarted( instance.id, instance.directory, ); + log("resolving session"); const sessionId = await this.resolveSession( runtime.client, instance, job, ); this.runningSessionIds.set(job.id, sessionId); - - const response = await runtime.client.session.prompt({ - sessionID: sessionId, - directory: instance.directory, - agent: job.task.agent, - model: job.task.model - ? { - providerID: job.task.model.providerId, - modelID: job.task.model.modelId, + log(`session=${sessionId}`); + + switch (job.task.type) { + case "prompt": { + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + const NO_INFO_TIMEOUT_MS = 30 * 1000; + const idlePromise = new Promise((resolve) => { + this.sessionIdleWaiters.set(sessionId, resolve); + }); + log(`sending prompt: "${job.task.prompt.slice(0, 50)}"`); + const response = await runtime.client.session.prompt({ + sessionID: sessionId, + directory: instance.directory, + agent: job.task.agent, + model: job.task.model + ? { + providerID: job.task.model.providerId, + modelID: job.task.model.modelId, + } + : undefined, + system: job.task.system, + variant: job.task.variant, + parts: [{ type: "text", text: job.task.prompt }], + }); + patch.messageId = response.info?.id; + const finalText = extractText(response.parts ?? []); + const current = this.store.getJob(job.id); + if (!current?.outputText || current.outputText.length === 0) { + patch.outputText = finalText; + } + const hasOutput = + (patch.outputText && patch.outputText.length > 0) || + (current?.outputText && current.outputText.length > 0); + if (!hasOutput) { + log("prompt sent, awaiting idle"); + try { + await Promise.race([ + idlePromise, + response.info + ? new Promise((_, reject) => { + setTimeout( + () => reject(new Error("session.idle timeout")), + IDLE_TIMEOUT_MS, + ); + }) + : new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error("OpenCode returned no message data"), + ), + NO_INFO_TIMEOUT_MS, + ); + }), + ]); + log("idle received"); + } catch { + log("idle timeout — completing anyway"); + this.sessionIdleWaiters.delete(sessionId); } - : undefined, - system: job.task.system, - variant: job.task.variant, - parts: [{ type: "text", text: job.task.prompt }], - }); - patch.messageId = response.info.id; - // Prefer accumulated text from streamed deltas; fall back to the - // final parts payload if no deltas were received (non-streaming - // providers). Any deltas already landed in output_text via - // appendJobOutput, so only write the fallback when nothing was - // accumulated. - const current = this.store.getJob(job.id); - if (!current?.outputText || current.outputText.length === 0) { - patch.outputText = extractText(response.parts); - } - if (controller.signal.aborted) { - // prompt() returned successfully but the job was cancelled before - // the abort was observed — record the cancellation reason. - terminalState = "cancelled"; - patch.error = "Job cancelled"; - } else { - terminalState = "succeeded"; + } + const sessionError = this.sessionErrors.get(sessionId); + if (sessionError) { + terminalState = controller.signal.aborted ? "cancelled" : "failed"; + patch.error = controller.signal.aborted + ? "Job cancelled" + : sessionError; + } else if (!hasOutput) { + terminalState = controller.signal.aborted ? "cancelled" : "failed"; + patch.error = controller.signal.aborted + ? "Job cancelled" + : "OpenCode returned no response. Check provider credentials and model availability."; + } else if (controller.signal.aborted) { + terminalState = "cancelled"; + patch.error = "Job cancelled"; + } else { + terminalState = "succeeded"; + } + log( + `job ${terminalState}, outputText length: ${patch.outputText?.length ?? current?.outputText?.length ?? 0}`, + ); + break; + } } } catch (error) { terminalState = controller.signal.aborted ? "cancelled" : "failed"; patch.error = controller.signal.aborted ? "Job cancelled" : normalizeErrorMessage(error); + log(`job error: ${patch.error}`); + } finally { + if (job.sessionId) { + this.sessionIdleWaiters.delete(job.sessionId); + this.sessionErrors.delete(job.sessionId); + } } const finalJob = this.store.markJobTerminal(job.id, terminalState, patch); @@ -778,9 +957,14 @@ export class Daemon { if (sessionRef.remoteSessionId) return sessionRef.remoteSessionId; const title = deriveSessionTitle(sessionRef, job); + const permission = this.pendingPermissions.get(job.id) ?? [ + { permission: "*", pattern: "*", action: "allow" }, + ]; + this.pendingPermissions.delete(job.id); const session = await client.session.create({ directory: instance.directory, title, + permission, }); this.store.assignRemoteSessionToJob(job.id, session.id, title); return session.id; @@ -987,6 +1171,26 @@ export function createConnectionHandler(daemon: Daemon) { return; } + if (request.data.method === "job.submit_and_stream") { + void daemon.handleRequest(request.data).then((ack) => { + if (!writeLine(ack)) return; + if (!ack.ok) return; + + const jobId = (ack.result as SubmitResult).job.id; + const unsub = daemon.subscribeJob(jobId, (event) => { + if (socket.writable) { + socket.write(`${JSON.stringify(event)}\n`); + } + if (event.type === "done" || event.type === "error") { + socket.end(); + } + }); + socket.on("close", unsub); + daemon.scheduleDrain(); + }); + return; + } + if (request.data.method === "job.stream") { const { jobId } = request.data.params as { jobId: string }; void daemon.handleRequest(request.data).then((ack) => {