From 6d0093bdb71db7a1fa763ad0074a01daaa6e1939 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Wed, 1 Apr 2026 21:01:24 -0500 Subject: [PATCH 01/13] fix: address review issues --- activity-log.md | 8 + dashboard/server.js | 97 +++- dashboard/src/App.jsx | 15 +- dashboard/src/components/AgentModelPicker.jsx | 70 +++ .../src/components/DispatchSettingsRow.jsx | 18 +- dashboard/src/components/DispatchView.jsx | 497 ++++++++++++----- dashboard/src/components/LoopDetailView.jsx | 221 +++++--- dashboard/src/components/LoopReviewPanel.jsx | 175 ++++++ dashboard/src/components/LoopsView.jsx | 522 ++++++------------ dashboard/src/components/SettingsView.jsx | 20 +- dashboard/src/components/mdComponents.jsx | 38 +- dashboard/src/lib/useAppNavigation.js | 15 +- dashboard/src/lib/useSessionStore.js | 1 + dashboard/src/lib/useSettings.js | 2 + docs/agent-api.md | 5 +- loops/parallel-review.sh | 28 +- parsers.js | 84 +++ todo.md | 2 +- 18 files changed, 1199 insertions(+), 619 deletions(-) create mode 100644 dashboard/src/components/AgentModelPicker.jsx create mode 100644 dashboard/src/components/LoopReviewPanel.jsx diff --git a/activity-log.md b/activity-log.md index 692303e..aab98b6 100644 --- a/activity-log.md +++ b/activity-log.md @@ -2,6 +2,14 @@ **Current stage:** Getting started +## 2026-04-02 + +- **Please implement code diffs in the markdown rendering for loops and tasks** +- **Determine why dispatch is failing for cursor agents** +- **Is that the correct way to do this? I still see many listed as active on the loops page --- Previous job context: notes/jobs/2026-04-02-please-mark-all-existing-loops-for-prompt-guard-an.md** + +--- + ## 2026-03-31 - **Full loop.log ownership** — eliminated job files for loops; scripts write structured headers (LOOP_SESSION, LOOP_TYPE, LOOP_AGENT, LOOP_STARTED) and LOOP_STATUS to loop.log; added parseLoopRun/parseAllLoopRuns to parsers.js; rewrote /api/loops to scan .dispatch/loops/ dirs; created dedicated LoopDetailView with own /loops/:type/:timestamp route and terminal panel. skills: /done diff --git a/dashboard/server.js b/dashboard/server.js index 61c6681..61e20a0 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -43,7 +43,7 @@ const require = createRequire(import.meta.url) const pty = require('node-pty') const __dirname = path.dirname(fileURLToPath(import.meta.url)) const { - parseTaskFile, parseActivityLog, getGitInfo, parseJobFile, parseJobDir, parsePlansDir, parseFrontmatter, parseSkillsDir, loadConfig, parseLoopState, parseAllLoopRuns, + parseTaskFile, parseActivityLog, getGitInfo, parseJobFile, parseJobDir, parsePlansDir, parseFrontmatter, parseSkillsDir, loadConfig, parseLoopState, parseAllLoopRuns, parseLoopRunDetailed, writeTaskDone, writeTaskDoneByText, writeTaskReopenByText, writeTaskAdd, writeTaskEdit, writeTaskMove, writeActivityEntry, writeJobValidation, writeJobKill, writeJobStatus, writeJobResults, createCheckpoint, revertCheckpoint, dismissCheckpoint, listCheckpoints, @@ -267,6 +267,13 @@ function buildResumeClaudeCommand(resumeId, flags = '') { return `claude${flags} --resume "${id}"` } +function buildResumeCursorCommand(resumeId, flags = '') { + const id = String(resumeId ?? '').trim() + if (!id) return null + if (!isValidResumeId(id)) return null + return `cursor-agent${flags} --resume "${id}"` +} + function buildDispatchPrompt(taskText, jobFilePath = null, selectedSkillNames = [], worktreePath = null) { let prompt = '' @@ -392,6 +399,32 @@ function buildTrackedCodexCommand({ return `${cmd}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` } +function buildTrackedCursorCommand({ + promptFilePath = null, + model = null, + skipPermissions = false, + plainOutput = false, + resumeId = null, + sessionId = null, + jobId = null, + repoName = null, + extraFlags = '', +} = {}) { + if (!promptFilePath && !resumeId) return null + let flags = '' + if (skipPermissions) flags += ' --force' + if (model) flags += ` --model ${shellQuote(model)}` + if (plainOutput) flags += ' --print --output-format text --trust' + const sanitized = sanitizeExtraFlags(extraFlags) + if (sanitized) flags += ` ${sanitized}` + const envPrefix = buildClaudeEnvPrefix({ sessionId, jobId, repoName }) + const cursorCommand = resumeId + ? `${envPrefix}${buildResumeCursorCommand(resumeId, flags)}` + : `${envPrefix}cursor-agent${flags} "$(cat ${shellQuote(promptFilePath)})"` + const withExit = `${cursorCommand}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` + return plainOutput ? `echo '__HUB_OUTPUT_START__'; ${withExit}` : withExit +} + // Strip ANSI escape sequences for clean log output function stripAnsi(str) { return str @@ -800,7 +833,7 @@ app.get('/api/loops', (req, res) => { for (const run of runs) { const dirName = path.basename(run.runDir) const id = `${loopType}/${dirName}` - const sessionActive = run.session ? ptySessions.has(run.session) : false + const sessionActive = run.session ? ptySessions.get(run.session)?.alive === true : false let status = 'unknown' if (sessionActive) status = 'in_progress' else if (run.loopStatus === 'completed' || run.complete) status = 'completed' @@ -905,6 +938,38 @@ app.post('/api/loops/init', (req, res) => { res.json({ sessionId, shellCommand }) }) +app.get('/api/loops/:loopType/:timestamp/artifacts', (req, res) => { + const { loopType, timestamp } = req.params + if (!VALID_LOOP_TYPES.has(loopType)) { + return res.status(400).json({ error: `Invalid loop type` }) + } + try { + const config = getConfig() + // Search all repos for the matching run directory + for (const repo of config.repos) { + const runDir = path.join(repo.resolvedPath, '.dispatch', 'loops', loopType, timestamp) + if (!fs.existsSync(runDir)) continue + + try { + const detailed = parseLoopRunDetailed(runDir) + return res.json({ + iterations: detailed.iterations, + artifacts: detailed.artifacts, + prompt: detailed.prompt, + warnings: detailed.warnings || [], + }) + } catch (err) { + console.error('[loops/artifacts] failed to parse run', { runDir, error: err?.message || String(err) }) + return res.status(500).json({ error: 'Failed to parse loop artifacts' }) + } + } + return res.status(404).json({ error: 'Loop run not found' }) + } catch (err) { + console.error('[loops/artifacts] failed to load config', { error: err?.message || String(err) }) + return res.status(500).json({ error: 'Failed to load loop artifacts' }) + } +}) + // ── End loop endpoints ────────────────────────────────────────────────────── function compareAgentsByStart(a, b) { @@ -1714,6 +1779,7 @@ app.post(['/api/jobs/:id/resume', '/api/swarm/:id/resume'], (req, res) => { try { const detail = parseJobFile(found.filePath) + const agentId = detail.agent || 'claude' const sessionId = detail.session || null if (!sessionId || !isValidSessionId(sessionId)) { return res.status(409).json({ @@ -1727,8 +1793,12 @@ app.post(['/api/jobs/:id/resume', '/api/swarm/:id/resume'], (req, res) => { }) } const skipPermissions = detail.skipPermissions == null ? true : detail.skipPermissions === true - const resumeFlags = skipPermissions ? ' --dangerously-skip-permissions' : '' - const resumeCommand = buildResumeClaudeCommand(resumeId, resumeFlags) + const resumeFlags = agentId === 'cursor' + ? (skipPermissions ? ' --force' : '') + : (skipPermissions ? ' --dangerously-skip-permissions' : '') + const resumeCommand = agentId === 'cursor' + ? buildResumeCursorCommand(resumeId, resumeFlags) + : buildResumeClaudeCommand(resumeId, resumeFlags) if (!resumeCommand) { return res.status(409).json({ error: 'No resume command is recorded for this job yet.', @@ -1768,6 +1838,7 @@ app.post(['/api/jobs/:id/resume', '/api/swarm/:id/resume'], (req, res) => { sessionId, jobId: req.params.id, repoName: found.repo?.name || null, + agent: agentId, } ) @@ -1797,6 +1868,7 @@ app.post(['/api/jobs/:id/resume', '/api/swarm/:id/resume'], (req, res) => { resumeCommand, resumeId, skipPermissions, + agent: agentId, taskText: detail.taskName || detail.originalTask || req.params.id, status: 'in_progress', validation: 'none', @@ -2370,7 +2442,9 @@ function startPendingLaunch(session) { ? (shellCommand ? `${shellCommand}\n` : null) : launch.agent === 'codex' ? buildTrackedCodexCommand(launch) - : buildTrackedClaudeCommand(launch) + : launch.agent === 'cursor' + ? buildTrackedCursorCommand(launch) + : buildTrackedClaudeCommand(launch) if (!command) return session.launchStarted = true if (launch.agent !== 'shell') { @@ -2587,15 +2661,20 @@ function createPtySession(sessionId, cwd, repoName, jobFilePath, initialScrollba } const stripped = stripAnsi(chunk) - // Capture resume command emitted by Claude so the job can be resumed later. - const cmdMatch = stripped.match(/(claude(?:\s+code)?\s+(?:resume|--resume)\s+(?:["'])?[A-Za-z0-9._:-]+(?:["'])?)/i) + // Capture resume commands emitted by supported agent CLIs so the job can be resumed later. + const cmdMatch = stripped.match(/((?:claude(?:\s+code)?|cursor-agent)\s+(?:resume|--resume)\s+(?:["'])?[A-Za-z0-9._:-]+(?:["'])?)/i) if (cmdMatch) { const nextResumeId = extractResumeId(cmdMatch[1]) const headerDetail = session.jobFilePath && fs.existsSync(session.jobFilePath) ? parseJobFile(session.jobFilePath) : null - const resumeFlags = headerDetail?.skipPermissions ? ' --dangerously-skip-permissions' : '' - const nextResumeCommand = buildResumeClaudeCommand(nextResumeId, resumeFlags) + const sessionAgent = headerDetail?.agent || session.pendingLaunch?.agent || 'claude' + const resumeFlags = sessionAgent === 'cursor' + ? (headerDetail?.skipPermissions ? ' --force' : '') + : (headerDetail?.skipPermissions ? ' --dangerously-skip-permissions' : '') + const nextResumeCommand = sessionAgent === 'cursor' + ? buildResumeCursorCommand(nextResumeId, resumeFlags) + : buildResumeClaudeCommand(nextResumeId, resumeFlags) if (nextResumeCommand && nextResumeCommand !== session.resumeCommand) { session.resumeCommand = nextResumeCommand session.resumeId = nextResumeId diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index 11addb8..fff5691 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -62,6 +62,7 @@ export default function App() { handleNavChange, openJobDetail, openLoopDetail, + openLoopBySession, openDispatch, closeJobDetail, } = useAppNavigation() @@ -141,6 +142,17 @@ export default function App() { await startTaskSession(taskText, repo, { originalTask, baseBranch, model, maxTurns, autoMerge, useWorktree, plainOutput, skipPermissions, agent: agentId, planSlug, skills }) }, [startTaskSession, settings]) + const handleLoopDispatch = useCallback(async (body) => { + const res = await fetch('/api/loops/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || 'Loop launch failed') + if (data.sessionId) openLoopBySession(data.sessionId) + }, [openLoopBySession]) + const handleResumeJob = useCallback(async (jobId) => { const sessionId = await resumeJobSession(jobId) if (!sessionId) return @@ -260,6 +272,7 @@ export default function App() { /> } /> + @@ -267,7 +280,6 @@ export default function App() { loops={loops.data} overview={overview.data} onSelectLoop={openLoopDetail} - onSelectSession={openJobDetail} /> } /> @@ -308,6 +320,7 @@ export default function App() { { + if (models.length > 0 && !models.find(m => m.value === value.model)) { + onChange({ ...value, model: models[0].value }) + } + }, [models, onChange, value]) + + return ( +
+ {label && } +
+
+ {AGENT_OPTIONS.map(opt => { + const isSelected = value.agent === opt.id + const brandColor = getAgentBrandColor(opt.id) + return ( + + ) + })} +
+ +
+
+ ) +} + +export function defaultAgentModel() { + return { agent: 'claude', model: '' } +} + +export function fmtAgent({ agent, model }) { + return model ? `${agent}:${model}` : agent +} diff --git a/dashboard/src/components/DispatchSettingsRow.jsx b/dashboard/src/components/DispatchSettingsRow.jsx index e175e05..754a458 100644 --- a/dashboard/src/components/DispatchSettingsRow.jsx +++ b/dashboard/src/components/DispatchSettingsRow.jsx @@ -15,7 +15,7 @@ export default function DispatchSettingsRow({ autoMerge, setAutoMerge, plainOutput, setPlainOutput, }) { - const isCodex = agent === 'codex' + const turnsUnsupported = agent === 'codex' || agent === 'cursor' return ( <> {/* Agent selector */} @@ -67,14 +67,14 @@ export default function DispatchSettingsRow({ type="number" min={1} max={200} - value={isCodex ? '' : (maxTurns ?? '')} - disabled={isCodex} + value={turnsUnsupported ? '' : (maxTurns ?? '')} + disabled={turnsUnsupported} onChange={(e) => setMaxTurns(parseInt(e.target.value) || 10)} - placeholder={isCodex ? 'N/A' : '10'} - title={isCodex ? 'N/A for Codex' : undefined} + placeholder={turnsUnsupported ? 'N/A' : '10'} + title={turnsUnsupported ? `N/A for ${agent === 'cursor' ? 'Cursor' : 'Codex'}` : undefined} className={cn( 'w-full h-8 px-2.5 rounded-md border border-border bg-card text-[12px] text-foreground font-mono focus:outline-none focus:border-primary/30', - isCodex && 'opacity-40 cursor-not-allowed' + turnsUnsupported && 'opacity-40 cursor-not-allowed' )} style={{ fontFamily: 'var(--font-mono)' }} /> @@ -87,7 +87,11 @@ export default function DispatchSettingsRow({ setPlainOutput(!val)} - title={isCodex ? 'TUI mode — off adds --quiet' : 'TUI mode — off adds -p --output-format text'} + title={agent === 'codex' + ? 'TUI mode — off adds --color never' + : agent === 'cursor' + ? 'TUI mode — off adds --print --output-format text' + : 'TUI mode — off adds -p --output-format text'} /> diff --git a/dashboard/src/components/DispatchView.jsx b/dashboard/src/components/DispatchView.jsx index e1b8f72..68be30f 100644 --- a/dashboard/src/components/DispatchView.jsx +++ b/dashboard/src/components/DispatchView.jsx @@ -1,11 +1,12 @@ -import { useState, useEffect, useRef } from 'react' -import { Send, FileText, X } from 'lucide-react' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Send, FileText, X, RefreshCcw } from 'lucide-react' import { cn } from '../lib/utils' import { repoIdentityColors } from '../lib/constants' import { useAgentModels } from '../lib/useAgentModels' import { useSkills } from '../lib/useSkills' import DispatchSettingsRow from './DispatchSettingsRow' import SkillsSelector from './SkillsSelector' +import { AgentModelPicker, LOOP_TYPES, defaultAgentModel, fmtAgent } from './AgentModelPicker' function readSaved() { try { return JSON.parse(localStorage.getItem('dispatch-settings')) || {} } @@ -19,7 +20,7 @@ function writeSaved(patch) { } catch {} } -export default function DispatchView({ overview, onDispatch, initialRepo, initialPrompt, initialPlanSlug, initialSkills, onDispatchComplete, settings }) { +export default function DispatchView({ overview, onDispatch, onLoopDispatch, initialRepo, initialPrompt, initialPlanSlug, initialSkills, onDispatchComplete, settings }) { const repos = overview?.repos || [] // Read from localStorage once on mount (sync, before useState defaults) @@ -29,6 +30,7 @@ export default function DispatchView({ overview, onDispatch, initialRepo, initia const agentSettings = settings?.agents || {} + const [mode, setMode] = useState(() => s.dispatchMode || 'task') const [agent, setAgent] = useState(s.agent || 'claude') const [repo, setRepo] = useState(initialRepo || s.repo || repos[0]?.name || '') const [baseBranch, setBaseBranch] = useState('') @@ -42,6 +44,15 @@ export default function DispatchView({ overview, onDispatch, initialRepo, initia const [btnPhase, setBtnPhase] = useState('idle') // idle | shaking | sliding | hidden | returning const [dispatchError, setDispatchError] = useState(null) + // Loop mode state + const [loopType, setLoopType] = useState(s.loopType || 'linear-implementation') + const [loopPrompt, setLoopPrompt] = useState('') + const [loopAgentSpec, setLoopAgentSpec] = useState(defaultAgentModel()) + const [reviewers, setReviewers] = useState([defaultAgentModel()]) + const [synthesizer, setSynthesizer] = useState(defaultAgentModel()) + const [implementor, setImplementor] = useState(defaultAgentModel()) + const [loopBtnPhase, setLoopBtnPhase] = useState('idle') + const isCodex = agent === 'codex' const models = useAgentModels(agent) const availableSkills = useSkills() @@ -86,6 +97,8 @@ export default function DispatchView({ overview, onDispatch, initialRepo, initia useEffect(() => { writeSaved({ useWorktree }) }, [useWorktree]) useEffect(() => { writeSaved({ plainOutput }) }, [plainOutput]) useEffect(() => { writeSaved({ prompt }) }, [prompt]) + useEffect(() => { writeSaved({ dispatchMode: mode }) }, [mode]) + useEffect(() => { writeSaved({ loopType }) }, [loopType]) // Set default repo once repos load (overview may not be ready on first render) useEffect(() => { @@ -108,11 +121,23 @@ export default function DispatchView({ overview, onDispatch, initialRepo, initia if (sanitized.length !== selectedSkills.length) setSelectedSkills(sanitized) }, [availableSkills, selectedSkills]) + // Load loop prompt when repo or loopType changes (loop mode) + useEffect(() => { + if (mode !== 'loop' || !repo || !loopType) return + let cancelled = false + fetch(`/api/loops/${encodeURIComponent(repo)}/prompt?type=${encodeURIComponent(loopType)}`) + .then(r => r.json()) + .then(d => { if (!cancelled) setLoopPrompt(d.content || '') }) + .catch(() => { if (!cancelled) setLoopPrompt('') }) + return () => { cancelled = true } + }, [repo, loopType, mode]) + const selectedRepo = repos.find(r => r.name === repo) const defaultBranch = selectedRepo?.git?.branch || 'main' const branchOptions = selectedRepo?.git?.branches || [] const isReady = repo && (planSlug || prompt.trim()) + const isLoopReady = !!repo async function handleDispatch(e) { e.preventDefault() @@ -160,173 +185,353 @@ export default function DispatchView({ overview, onDispatch, initialRepo, initia } } + const handleLoopLaunch = useCallback(async () => { + if (!isLoopReady || loopBtnPhase !== 'idle') return + setDispatchError(null) + + setLoopBtnPhase('shaking') + setTimeout(() => setLoopBtnPhase('sliding'), 400) + setTimeout(() => setLoopBtnPhase('hidden'), 800) + + try { + const body = { repo, loopType, promptContent: loopPrompt } + if (loopType === 'parallel-review') { + body.reviewerAgents = reviewers.map(fmtAgent) + body.synthesizerAgent = fmtAgent(synthesizer) + body.implementorAgent = fmtAgent(implementor) + } else { + body.agentSpec = fmtAgent(loopAgentSpec) + } + await onLoopDispatch?.(body) + setTimeout(() => setLoopBtnPhase('returning'), 1800) + setTimeout(() => setLoopBtnPhase('idle'), 2400) + } catch (err) { + console.error('Loop launch failed:', err) + setLoopBtnPhase('idle') + setDispatchError(err.message) + } + }, [repo, loopType, loopPrompt, loopAgentSpec, reviewers, synthesizer, implementor, onLoopDispatch, isLoopReady, loopBtnPhase]) + + const repoChips = ( +
+ +
+ {repos.map(r => { + const color = repoIdentityColors[r.name] || 'var(--primary)' + const isSelected = repo === r.name + return ( + + ) + })} +
+
+ ) + + function renderGlowButton({ isReadyFlag, phase, icon: Icon, label, onClick, type = 'button' }) { + return ( +
+ +
+
+
+
+
+
+ + +
+
+ ) + } + return (
-
- - {/* Repo row */} -
- -
- {repos.map(r => { - const color = repoIdentityColors[r.name] || 'var(--primary)' - const isSelected = repo === r.name - return ( + {/* Mode toggle */} +
+ + +
+ + {mode === 'task' ? ( + /* ── Task mode (existing form) ── */ + + {repoChips} + + {/* Branch row */} +
+
+ + +
+
+ + + + {/* Plan chip */} + {planSlug && ( +
+ +
+ + + plans/{planSlug}.md + - ) - })} -
-
+
+
+ )} - {/* Branch row */} -
-
-