-
-
-
- {loop.status === 'in_progress'
- ? 'Terminal session not found — it may still be initializing.'
- : 'This loop has ended. No live terminal available.'}
-
- {loop.loopState?.complete && (
-
- Final status: {loop.loopState.lastVerdict || loop.loopState.loopStatus || 'completed'}
+ {/* Content area — terminal always mounted (hidden/shown), review conditionally rendered */}
+
+
+ {hasTerminal ? (
+
+ ) : (
+
+
+
+
+ {loop.status === 'in_progress'
+ ? 'Terminal session not found — it may still be initializing.'
+ : 'This loop has ended. No live terminal available.'}
- )}
+
+ )}
+
+
+
)
diff --git a/dashboard/src/components/LoopReviewPanel.jsx b/dashboard/src/components/LoopReviewPanel.jsx
new file mode 100644
index 0000000..2f50d7f
--- /dev/null
+++ b/dashboard/src/components/LoopReviewPanel.jsx
@@ -0,0 +1,322 @@
+import { useMemo } from 'react'
+import { Clock, RefreshCcw, CheckCircle2, XCircle, Loader2, ChevronRight } from 'lucide-react'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { cn, timeAgo } from '../lib/utils'
+import { repoIdentityColors, normalizeAgentId, getAgentBrandColor } from '../lib/constants'
+import { LOOP_TYPE_META } from '../lib/loopConstants'
+import { usePolling } from '../lib/usePolling'
+import { mdComponents } from './mdComponents'
+import AgentIcon, { getAgentLabel } from './AgentIcon'
+
+const VERDICT_COLORS = {
+ PASS: '#4ade80',
+ FAIL: '#f87171',
+}
+
+const TRANSCRIPT_META_RE = /^(OpenAI |--------$|workdir: |model: |provider: |approval: |sandbox: |reasoning effort: |reasoning summaries: |session id: |user$|assistant$|mcp startup:|codex$|claude$|cursor$|tokens used$|\d{1,3}(,\d{3})*$)/
+const SUMMARY_LINE_RE = /^(Implemented:|Findings:|VERDICT:|VERIFIED:|ALL ISSUES RESOLVED|Original findings:|\d+\.\s)/
+
+function normalizeArtifactText(text) {
+ return String(text || '').replace(/\r\n?/g, '\n').trim()
+}
+
+function isTranscriptMetaLine(line) {
+ return TRANSCRIPT_META_RE.test(String(line || '').trim())
+}
+
+function isDiffStart(line) {
+ return /^diff --git /.test(line)
+}
+
+function isDiffLine(line) {
+ return /^(diff --git |index |--- |\+\+\+ |@@ |[ +\-].*|\\ No newline at end of file)/.test(line)
+}
+
+function isDiffMetadataLine(line) {
+ return /^(diff --git |index |--- [ab]\/|\+\+\+ [ab]\/|@@ )/.test(String(line || '').trim())
+}
+
+function formatArtifactContent(text) {
+ const normalized = normalizeArtifactText(text)
+ if (!normalized) return '*Empty*'
+
+ const lines = normalized.split('\n')
+ const blocks = []
+
+ for (let idx = 0; idx < lines.length;) {
+ const line = lines[idx]
+
+ if (isDiffStart(line)) {
+ const diffLines = []
+ while (idx < lines.length && (lines[idx] === '' || isDiffLine(lines[idx]))) {
+ diffLines.push(lines[idx])
+ idx += 1
+ }
+ blocks.push(`\`\`\`diff\n${diffLines.join('\n')}\n\`\`\``)
+ continue
+ }
+
+ if (isTranscriptMetaLine(line)) {
+ const metaLines = []
+ while (idx < lines.length && (lines[idx] === '' || isTranscriptMetaLine(lines[idx]))) {
+ metaLines.push(lines[idx])
+ idx += 1
+ }
+ blocks.push(`\`\`\`text\n${metaLines.join('\n')}\n\`\`\``)
+ continue
+ }
+
+ blocks.push(line)
+ idx += 1
+ }
+
+ return blocks.join('\n')
+}
+
+function collectSummaryLines(lines, startIndex) {
+ const collected = []
+ let blankCount = 0
+
+ for (let idx = startIndex; idx < lines.length; idx += 1) {
+ const line = lines[idx]
+ const trimmed = line.trim()
+ if (!trimmed) {
+ blankCount += 1
+ if (blankCount > 1 && collected.length > 0) break
+ continue
+ }
+ blankCount = 0
+ if (isTranscriptMetaLine(trimmed) || isDiffMetadataLine(trimmed)) continue
+ collected.push(trimmed)
+ if (collected.length >= 6) break
+ }
+
+ return collected
+}
+
+function summarizeArtifact(text, type) {
+ const normalized = normalizeArtifactText(text)
+ if (!normalized) return { status: 'Empty artifact', details: [] }
+
+ const lines = normalized.split('\n')
+ const verdictLine = [...lines].reverse().find(line => /^(VERDICT:|VERIFIED:|ALL ISSUES RESOLVED)/.test(line.trim()))
+ const verdict = verdictLine ? verdictLine.trim() : null
+
+ if (verdict === 'ALL ISSUES RESOLVED') {
+ return { status: verdict, details: [] }
+ }
+
+ const findingsStart = lines.findIndex(line => /^Findings:\s*$/.test(line.trim()))
+ const implementedStart = lines.findIndex(line => /^Implemented:\s*$/.test(line.trim()))
+ const numberedFindings = lines
+ .filter(line => /^\d+\.\s+/.test(line.trim()))
+ .slice(0, 3)
+ .map(line => line.trim())
+
+ const details = findingsStart >= 0
+ ? collectSummaryLines(lines, findingsStart).filter(line => line !== 'Findings:')
+ : numberedFindings
+
+ if (details.length > 0) {
+ return { status: verdict || `${details.length} key finding${details.length === 1 ? '' : 's'}`, details }
+ }
+
+ if (implementedStart >= 0) {
+ return {
+ status: verdict || (type === 'review' ? 'Review summary' : 'Summary'),
+ details: collectSummaryLines(lines, implementedStart).filter(line => line !== 'Implemented:'),
+ }
+ }
+
+ const generic = lines.find(line => SUMMARY_LINE_RE.test(line.trim()) && !isTranscriptMetaLine(line))
+ if (generic) {
+ return { status: verdict || generic.trim(), details: verdict && generic.trim() !== verdict ? [generic.trim()] : [] }
+ }
+
+ return { status: verdict || 'Open artifact', details: [] }
+}
+
+export default function LoopReviewPanel({ loop }) {
+ const meta = LOOP_TYPE_META[loop?.loopType] || { label: loop?.loopType || 'Unknown', icon: RefreshCcw }
+ const TypeIcon = meta.icon
+ const repoColor = repoIdentityColors[loop?.repo] || 'var(--primary)'
+ const agentId = normalizeAgentId((loop?.agent || 'claude').split(':')[0])
+ const agentLabel = getAgentLabel(agentId)
+ const agentColor = getAgentBrandColor(agentId)
+ const duration = loop?.durationMinutes != null ? timeAgo(null, loop.durationMinutes) : null
+ const isActive = loop?.status === 'in_progress'
+
+ // Build artifacts URL from loop identity.
+ const artifactsUrl = useMemo(() => {
+ if (!loop?.id || !loop?.repo || !loop?.loopType) return null
+ const parts = loop.id.split('/')
+ const timestamp = parts.length >= 3 ? parts.slice(2).join('/') : parts.slice(1).join('/')
+ if (!timestamp) return null
+ return `/api/loops/${encodeURIComponent(loop.loopType)}/${encodeURIComponent(timestamp)}/artifacts?repo=${encodeURIComponent(loop.repo)}`
+ }, [loop?.id, loop?.repo, loop?.loopType])
+
+ const artifacts = usePolling(artifactsUrl, isActive ? 10000 : null)
+ const data = artifacts.data
+ const iterations = data?.iterations || []
+ const artifactList = data?.artifacts || []
+
+ // Group artifacts by iteration, most recent first
+ const iterationGroups = useMemo(() => {
+ if (iterations.length === 0 && artifactList.length === 0) return []
+ // Collect iteration numbers from both sources
+ const iterNums = new Set([
+ ...iterations.map(i => i.number),
+ ...artifactList.filter(a => a.iteration != null).map(a => a.iteration),
+ ])
+ const groups = [...iterNums].sort((a, b) => b - a).map(num => {
+ const iterInfo = iterations.find(i => i.number === num)
+ const arts = artifactList.filter(a => a.iteration === num)
+ return { number: num, timestamp: iterInfo?.timestamp || null, verdict: iterInfo?.verdict || null, artifacts: arts }
+ })
+ return groups
+ }, [iterations, artifactList])
+
+ return (
+
+ {/* Metadata card */}
+
+
+
+
+
+
{meta.label}
+
+ {loop?.repo}
+
+
+
+ {loop?.agent || agentLabel}
+
+
+
+
+ {loop?.started && Started: {loop.started}}
+ {duration && (
+
+ {duration}
+
+ )}
+ {loop?.loopState?.iteration > 0 && (
+ Iterations: {loop.loopState.iteration}
+ )}
+ Status: {loop?.status === 'in_progress' ? 'running' : loop?.status}
+ {loop?.loopState?.lastVerdict && (
+ Verdict: {loop.loopState.lastVerdict}
+ )}
+
+
+
+ {/* Artifacts list grouped by iteration */}
+ {iterationGroups.length === 0 ? (
+
+ {isActive ? (
+ <>
+
+
Awaiting review output…
+ >
+ ) : (
+ <>
+
+
No review artifacts found.
+ >
+ )}
+
+ ) : (
+
+ {iterationGroups.map(group => {
+ const verdictColor = group.verdict ? (VERDICT_COLORS[group.verdict] || '#888') : null
+ return (
+
+ {/* Iteration header */}
+
+ Iteration {group.number}
+ {group.timestamp && (
+ {group.timestamp}
+ )}
+ {group.verdict && (
+
+ {group.verdict === 'PASS' ? : }
+ {group.verdict}
+
+ )}
+
+
+ {/* Artifact blocks */}
+ {group.artifacts.length === 0 ? (
+
No artifacts for this iteration.
+ ) : (
+
+ {group.artifacts.map(art => (
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+function ArtifactCard({ artifact }) {
+ const formattedContent = useMemo(() => formatArtifactContent(artifact.content), [artifact.content])
+ const summary = useMemo(() => summarizeArtifact(artifact.content, artifact.type), [artifact.content, artifact.type])
+
+ return (
+
+
+
+
+
+
+
+ {artifact.type}
+
+ {artifact.name}
+
+
+
{summary.status}
+ {summary.details.length > 0 && (
+
+ {summary.details.map(detail => (
+ -
+ {detail}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {formattedContent}
+
+
+
+ )
+}
diff --git a/dashboard/src/components/LoopsView.jsx b/dashboard/src/components/LoopsView.jsx
index 8031f28..b936844 100644
--- a/dashboard/src/components/LoopsView.jsx
+++ b/dashboard/src/components/LoopsView.jsx
@@ -1,396 +1,222 @@
-import { useState, useEffect, useCallback, useMemo } from 'react'
-import { RefreshCcw, Clock, Code2, ScanSearch, GitFork } from 'lucide-react'
+import { useState, useMemo, useEffect } from 'react'
+import { RefreshCcw, Clock, Code2, ScanSearch, GitFork, Sparkles, CheckCircle2, XCircle } from 'lucide-react'
import { cn, timeAgo } from '../lib/utils'
-import { repoIdentityColors, AGENT_OPTIONS, getAgentBrandColor } from '../lib/constants'
-import { useAgentModels } from '../lib/useAgentModels'
-import AgentIcon from './AgentIcon'
+import { repoIdentityColors } from '../lib/constants'
+import { FilterChip, toggleFilter, loadFilters, saveFilters } from '../lib/filterUtils.jsx'
+import { LOOP_TYPES } from './AgentModelPicker'
-const LOOP_TYPES = [
- { id: 'linear-implementation', label: 'Linear Impl', icon: Code2 },
- { id: 'linear-review', label: 'Linear Review', icon: ScanSearch },
- { id: 'parallel-review', label: 'Parallel Review', icon: GitFork },
-]
+const STORAGE_KEY = 'loopsView:filters'
-/** Compact agent + model picker — mirrors DispatchSettingsRow's Agent+Model section */
-function AgentModelPicker({ label, value, onChange }) {
- const models = useAgentModels(value.agent)
+const LOOP_STATUSES = ['active', 'completed', 'failed']
- // Snap model to first option when agent changes and current model isn't valid
- useEffect(() => {
- 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 (
-
- )
- })}
-
-
-
-
- )
+const STATUS_LABELS = {
+ active: 'Active',
+ completed: 'Completed',
+ failed: 'Failed',
}
-function defaultAgentModel() {
- return { agent: 'claude', model: '' }
+const STATUS_COLORS = {
+ active: '#4ade80',
+ completed: '#8bab8f',
+ failed: '#f87171',
}
-function fmtAgent({ agent, model }) {
- return model ? `${agent}:${model}` : agent
+const GROUP_CONFIG = {
+ active: { label: 'Active', dotClass: 'bg-status-active', icon: Sparkles },
+ completed: { label: 'Completed', dotClass: 'bg-status-complete', icon: CheckCircle2 },
+ failed: { label: 'Failed', dotClass: 'bg-status-failed', icon: XCircle },
}
function classifyLoop(job) {
if (job.status === 'completed' || job.loopState?.complete) return 'completed'
if (job.status === 'failed') return 'failed'
- if (job.status === 'in_progress') return 'active'
return 'active'
}
-const STATUS_COLORS = {
- active: '#4ade80',
- completed: '#8bab8f',
- rejected: '#f87171',
- failed: '#f87171',
-}
-
function getLoopTypeIcon(loopTypeId) {
return LOOP_TYPES.find(lt => lt.id === loopTypeId)?.icon || RefreshCcw
}
-export default function LoopsView({ loops, overview, onSelectLoop, onSelectSession }) {
+export default function LoopsView({ loops, overview, onSelectLoop }) {
const jobs = loops?.jobs || []
const repos = useMemo(() => (overview?.repos || []).map(r => r.name), [overview])
- const [repo, setRepo] = useState('')
- const [loopType, setLoopType] = useState('linear-implementation')
- const [prompt, setPrompt] = useState('')
- const [agentSpec, setAgentSpec] = useState(defaultAgentModel())
- const [reviewers, setReviewers] = useState([defaultAgentModel()])
- const [synthesizer, setSynthesizer] = useState(defaultAgentModel())
- const [implementor, setImplementor] = useState(defaultAgentModel())
- const [btnPhase, setBtnPhase] = useState('idle') // idle | shaking | sliding | hidden | returning
+ const allItems = useMemo(() => {
+ return jobs.map(job => ({ ...job, filterStatus: classifyLoop(job) }))
+ }, [jobs])
- // Default repo once repos are available
- useEffect(() => {
- if (repos.length > 0 && !repo) setRepo(repos[0])
- }, [repos])
+ const [savedFilters] = useState(() => loadFilters(STORAGE_KEY))
+ const [selectedStatuses, setSelectedStatuses] = useState(() => savedFilters?.statuses ?? new Set(LOOP_STATUSES))
+ const [selectedRepos, setSelectedRepos] = useState(() => savedFilters?.repos ?? new Set(repos))
+ const [selectedTypes, setSelectedTypes] = useState(() => savedFilters?.types ?? new Set(LOOP_TYPES.map(lt => lt.id)))
- // Load prompt when repo or loopType changes
+ // Sync repo filter when repos change (only if empty)
useEffect(() => {
- if (!repo || !loopType) return
- let cancelled = false
- fetch(`/api/loops/${encodeURIComponent(repo)}/prompt?type=${encodeURIComponent(loopType)}`)
- .then(r => r.json())
- .then(d => { if (!cancelled) setPrompt(d.content || '') })
- .catch(() => { if (!cancelled) setPrompt('') })
- return () => { cancelled = true }
- }, [repo, loopType])
-
- const isReady = !!repo
-
- const handleLaunch = useCallback(async () => {
- if (!isReady || btnPhase !== 'idle') return
-
- setBtnPhase('shaking')
- setTimeout(() => setBtnPhase('sliding'), 400)
- setTimeout(() => setBtnPhase('hidden'), 800)
-
- try {
- const body = { repo, loopType, promptContent: prompt }
- if (loopType === 'parallel-review') {
- body.reviewerAgents = reviewers.map(fmtAgent)
- body.synthesizerAgent = fmtAgent(synthesizer)
- body.implementorAgent = fmtAgent(implementor)
- } else {
- body.agentSpec = fmtAgent(agentSpec)
- }
- 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) onSelectSession?.(data.sessionId)
- setTimeout(() => setBtnPhase('returning'), 1800)
- setTimeout(() => setBtnPhase('idle'), 2400)
- } catch (err) {
- console.error('Loop launch failed:', err)
- setBtnPhase('idle')
+ if (repos.length > 0 && selectedRepos.size === 0) {
+ setSelectedRepos(new Set(repos))
}
- }, [repo, loopType, prompt, agentSpec, reviewers, synthesizer, implementor, onSelectSession, isReady, btnPhase])
+ }, [repos, selectedRepos.size])
+
+ useEffect(() => {
+ saveFilters(STORAGE_KEY, { statuses: selectedStatuses, repos: selectedRepos, types: selectedTypes })
+ }, [selectedStatuses, selectedRepos, selectedTypes])
+
+ const filteredItems = useMemo(() => {
+ return allItems.filter(w => {
+ if (selectedStatuses.size > 0 && !selectedStatuses.has(w.filterStatus)) return false
+ if (selectedRepos.size > 0 && !selectedRepos.has(w.repo)) return false
+ if (selectedTypes.size > 0 && !selectedTypes.has(w.loopType)) return false
+ return true
+ })
+ }, [allItems, selectedStatuses, selectedRepos, selectedTypes])
+
+ const groups = LOOP_STATUSES
+ .map(status => ({
+ ...GROUP_CONFIG[status],
+ status,
+ items: filteredItems
+ .filter(w => w.filterStatus === status)
+ .sort((a, b) => {
+ const aTs = a.started ? Date.parse(a.started) : 0
+ const bTs = b.started ? Date.parse(b.started) : 0
+ return bTs - aTs
+ }),
+ }))
return (
-
+
Loops
Multi-agent implementation and review loops.
- {/* Loop list */}
- {jobs.length === 0 ? (
-
No loop jobs yet. Launch one below.
- ) : (
-
- {jobs.map(job => {
- const repoColor = repoIdentityColors[job.repo] || 'var(--primary)'
- const category = classifyLoop(job)
- const statusColor = STATUS_COLORS[category] || STATUS_COLORS.active
- const duration = job.durationMinutes != null ? timeAgo(null, job.durationMinutes) : null
- const TypeIcon = getLoopTypeIcon(job.loopType)
- return (
-
onSelectLoop?.(job.id)}
- onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectLoop?.(job.id) } }}
- className="w-full text-left px-3.5 py-2.5 rounded-lg border bg-card hover:bg-card-hover transition-colors group cursor-pointer"
- style={{ borderColor: 'rgba(255,255,255,0.05)' }}
- onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(139,171,143,0.35)' }}
- onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)' }}
- >
-
-
-
-
-
-
{job.taskName || job.loopType || job.id}
-
- {duration && (
-
- {duration}
-
- )}
- {job.loopState?.iteration > 0 && (
-
- Iter {job.loopState.iteration}
- {job.loopState.lastVerdict && ` · ${job.loopState.lastVerdict}`}
-
- )}
-
-
-
-
- {job.repo}
-
-
- View
-
-
-
-
- )
- })}
-
- )}
-
- {/* Launch form */}
-
-
Launch Loop
-
- {/* Repo */}
-
-
-
- {repos.map(name => {
- const color = repoIdentityColors[name] || 'var(--primary)'
- const isSelected = repo === name
- return (
-
- )
- })}
-
+ {/* Filter chips */}
+
+
+
Status
+ {LOOP_STATUSES.map(st => (
+
toggleFilter(selectedStatuses, setSelectedStatuses, st)}
+ />
+ ))}
+
+
+
+ Type
+ {LOOP_TYPES.map(lt => (
+ toggleFilter(selectedTypes, setSelectedTypes, lt.id)}
+ />
+ ))}
- {/* Loop type */}
-
-
-
- {LOOP_TYPES.map(lt => {
- const isSelected = loopType === lt.id
- const Icon = lt.icon
- return (
-
- )
- })}
-
+
+ Repo
+ {repos.map(name => (
+ toggleFilter(selectedRepos, setSelectedRepos, name)}
+ color={repoIdentityColors[name]}
+ />
+ ))}
+
- {/* Prompt */}
-
)
diff --git a/dashboard/src/components/StatusView.jsx b/dashboard/src/components/StatusView.jsx
index 75b56af..4cd20a2 100644
--- a/dashboard/src/components/StatusView.jsx
+++ b/dashboard/src/components/StatusView.jsx
@@ -31,8 +31,8 @@ export default function StatusView({ overview, swarm }) {
color: repoIdentityColors[repo.name] || 'var(--primary)',
active: repoAgents.filter(a => a.status === 'in_progress').length,
completed: repoAgents.filter(a => a.status === 'completed').length,
- failed: repoAgents.filter(a => a.status === 'failed' || a.status === 'killed').length,
- review: repoAgents.filter(a => a.validation === 'needs_validation').length,
+ failed: repoAgents.filter(a => a.status === 'failed').length,
+ review: repoAgents.filter(a => a.validation === 'needs_validation' || a.status === 'stopped').length,
openTasks: repo.tasks?.openCount || 0,
branch: repo.git?.branch || '-',
dirty: repo.git?.isDirty || false,
diff --git a/dashboard/src/components/TerminalPanel.jsx b/dashboard/src/components/TerminalPanel.jsx
index f2ad02c..c6e8a1b 100644
--- a/dashboard/src/components/TerminalPanel.jsx
+++ b/dashboard/src/components/TerminalPanel.jsx
@@ -83,17 +83,21 @@ function AgentHeader({ taskInfo }) {
? 'Running'
: status === 'completed'
? 'Completed'
- : status === 'failed' || status === 'killed'
- ? 'Failed'
- : 'Starting'
+ : status === 'stopped' || status === 'killed'
+ ? 'Stopped'
+ : status === 'failed'
+ ? 'Failed'
+ : 'Starting'
const statusClass = status === 'in_progress'
? 'text-status-active bg-status-active-bg border-status-active-border'
: status === 'completed'
? 'text-status-complete bg-status-complete-bg border-status-complete-border'
- : status === 'failed' || status === 'killed'
- ? 'text-status-failed bg-status-failed-bg border-status-failed-border'
- : 'text-muted-foreground bg-card border-border'
+ : status === 'stopped' || status === 'killed'
+ ? 'text-status-review bg-status-review-bg border-status-review-border'
+ : status === 'failed'
+ ? 'text-status-failed bg-status-failed-bg border-status-failed-border'
+ : 'text-muted-foreground bg-card border-border'
return (
diff --git a/dashboard/src/components/mdComponents.jsx b/dashboard/src/components/mdComponents.jsx
index 4b548d4..e86ff93 100644
--- a/dashboard/src/components/mdComponents.jsx
+++ b/dashboard/src/components/mdComponents.jsx
@@ -2,6 +2,26 @@
* Shared react-markdown component overrides for consistent styling.
* Used by ResultsPanel, SwarmDetail.
*/
+function flattenCodeChildren(children) {
+ if (Array.isArray(children)) return children.map(flattenCodeChildren).join('')
+ if (typeof children === 'string') return children
+ if (children == null) return ''
+ return String(children)
+}
+
+function renderDiffLines(text) {
+ const lines = text.replace(/\n$/, '').split('\n')
+ return lines.map((line, idx) => {
+ let lineClass = 'block -mx-2 px-2'
+ if (line.startsWith('+++') || line.startsWith('---')) lineClass += ' text-sky-300 bg-sky-500/10'
+ else if (line.startsWith('+')) lineClass += ' text-emerald-300 bg-emerald-500/10'
+ else if (line.startsWith('-')) lineClass += ' text-rose-300 bg-rose-500/10'
+ else if (line.startsWith('@@')) lineClass += ' text-amber-300 bg-amber-500/10'
+ else if (line.startsWith('diff ') || line.startsWith('index ')) lineClass += ' text-muted-foreground/80'
+ return
{line || ' '}
+ })
+}
+
export const mdComponents = {
h1: ({ children }) =>
{children}
,
p: ({ children }) =>
{children}
,
@@ -21,8 +41,8 @@ export const mdComponents = {
{children}
),
- code: ({ node, children, ...props }) => {
- const text = Array.isArray(children) ? children.join('') : String(children || '')
+ code: ({ inline, className, node, children }) => {
+ const text = flattenCodeChildren(children)
if (text.startsWith('ts:')) {
const parts = text.slice(3).split('~~')
const relative = parts[0]
@@ -36,7 +56,19 @@ export const mdComponents = {
)
}
- const isInline = !node?.properties?.className
+ const isInline = typeof inline === 'boolean' ? inline : !node?.properties?.className
+ const languageMatch = String(className || '').match(/language-([A-Za-z0-9_-]+)/)
+ const language = languageMatch?.[1]?.toLowerCase()
+ const isDiffBlock = !isInline && (language === 'diff' || language === 'patch')
+
+ if (isDiffBlock) {
+ return (
+
+ {renderDiffLines(text)}
+
+ )
+ }
+
if (isInline) {
return (
diff --git a/dashboard/src/lib/apiFetchSetup.js b/dashboard/src/lib/apiFetchSetup.js
new file mode 100644
index 0000000..7bc282c
--- /dev/null
+++ b/dashboard/src/lib/apiFetchSetup.js
@@ -0,0 +1,34 @@
+/**
+ * When the hub uses DISPATCH_API_KEY, production builds must set
+ * VITE_DISPATCH_API_KEY to the same value so browser fetch() includes X-API-Key.
+ * Dev: Vite proxy injects the key from DISPATCH_API_KEY (see vite.config.js).
+ */
+const key = import.meta.env.VITE_DISPATCH_API_KEY
+if (key && typeof window !== 'undefined') {
+ const orig = window.fetch.bind(window)
+ const origin = window.location.origin
+
+ function shouldAttachApiKey(input) {
+ try {
+ const rawUrl = input instanceof Request
+ ? input.url
+ : typeof input === 'string' || input instanceof URL
+ ? String(input)
+ : ''
+ if (!rawUrl) return false
+ const url = new URL(rawUrl, window.location.href)
+ return url.origin === origin
+ } catch {
+ return false
+ }
+ }
+
+ window.fetch = (input, init = {}) => {
+ if (!shouldAttachApiKey(input)) return orig(input, init)
+ const next = { ...init }
+ const h = new Headers(init.headers || {})
+ h.set('X-API-Key', key)
+ next.headers = h
+ return orig(input, next)
+ }
+}
diff --git a/dashboard/src/lib/constants.js b/dashboard/src/lib/constants.js
index 59cd6d8..60e226c 100644
--- a/dashboard/src/lib/constants.js
+++ b/dashboard/src/lib/constants.js
@@ -44,6 +44,15 @@ export const CURSOR_MODEL_OPTIONS = [
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
]
+/**
+ * Available Pi models. Keep in sync with server.js FALLBACK_PI_MODELS.
+ */
+export const PI_MODEL_OPTIONS = [
+ { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
+ { value: 'openai-codex/gpt-5.4', label: 'GPT-5.4' },
+ { value: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4' },
+]
+
/**
* Supported agent providers.
*/
@@ -51,6 +60,7 @@ export const AGENT_OPTIONS = [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'cursor', label: 'Cursor' },
+ { id: 'pi', label: 'Pi' },
]
/**
@@ -61,6 +71,7 @@ export const AGENT_BRAND_COLORS = {
claude: '#D97757',
codex: '#924FF7',
cursor: '#2B7FFF',
+ pi: '#18A874',
}
/**
@@ -69,6 +80,7 @@ export const AGENT_BRAND_COLORS = {
export function normalizeAgentId(agent) {
if (agent === 'codex') return 'codex'
if (agent === 'cursor') return 'cursor'
+ if (agent === 'pi') return 'pi'
return 'claude'
}
diff --git a/dashboard/src/lib/filterUtils.jsx b/dashboard/src/lib/filterUtils.jsx
index 17e5601..14291d9 100644
--- a/dashboard/src/lib/filterUtils.jsx
+++ b/dashboard/src/lib/filterUtils.jsx
@@ -2,20 +2,37 @@ import { cn } from './utils'
export const BUG_COLOR = '#7ea89a'
+export function getFilterChipClassName(active, className = '') {
+ return cn(
+ 'border font-medium transition-colors',
+ active
+ ? 'hover:brightness-105'
+ : 'hover:brightness-110',
+ className
+ )
+}
+
+export function getFilterChipStyle(active, color) {
+ return {
+ backgroundColor: active
+ ? (color ? `${color}16` : 'var(--secondary)')
+ : 'var(--tertiary)',
+ color: active ? 'var(--secondary-foreground)' : 'var(--tertiary-foreground)',
+ borderColor: color
+ ? (active ? `${color}4a` : `${color}22`)
+ : (active ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.07)'),
+ boxShadow: active
+ ? (color ? `inset 0 0 0 1px ${color}10` : 'inset 0 0 0 1px rgba(255,255,255,0.03)')
+ : 'none',
+ }
+}
+
export function FilterChip({ label, active, onClick, color }) {
return (
diff --git a/dashboard/src/lib/loopConstants.js b/dashboard/src/lib/loopConstants.js
new file mode 100644
index 0000000..91e8143
--- /dev/null
+++ b/dashboard/src/lib/loopConstants.js
@@ -0,0 +1,7 @@
+import { Code2, ScanSearch, GitFork } from 'lucide-react'
+
+export const LOOP_TYPE_META = {
+ 'linear-implementation': { label: 'Linear Implementation', icon: Code2 },
+ 'linear-review': { label: 'Linear Review', icon: ScanSearch },
+ 'parallel-review': { label: 'Parallel Review', icon: GitFork },
+}
diff --git a/dashboard/src/lib/statusConfig.js b/dashboard/src/lib/statusConfig.js
index e74434d..e241265 100644
--- a/dashboard/src/lib/statusConfig.js
+++ b/dashboard/src/lib/statusConfig.js
@@ -1,4 +1,4 @@
-import { Activity, CheckCircle, XCircle, AlertCircle, Loader, Ban } from 'lucide-react'
+import { Activity, CheckCircle, XCircle, AlertCircle, Loader, Square } from 'lucide-react'
/**
* Status config for swarm agent states — icon, color, label, etc.
@@ -29,13 +29,21 @@ export const statusConfig = {
label: 'Failed',
dotColor: 'var(--status-failed)',
},
+ stopped: {
+ icon: Square,
+ color: 'text-status-review',
+ borderColor: 'var(--status-review-border)',
+ bg: 'bg-status-review-bg',
+ label: 'Stopped',
+ dotColor: 'var(--status-review)',
+ },
killed: {
- icon: Ban,
- color: 'text-status-failed',
- borderColor: 'var(--status-failed-border)',
- bg: 'bg-status-failed-bg',
- label: 'Killed',
- dotColor: 'var(--status-failed)',
+ icon: Square,
+ color: 'text-status-review',
+ borderColor: 'var(--status-review-border)',
+ bg: 'bg-status-review-bg',
+ label: 'Stopped',
+ dotColor: 'var(--status-review)',
},
needs_validation: {
icon: AlertCircle,
diff --git a/dashboard/src/lib/useAgentModels.js b/dashboard/src/lib/useAgentModels.js
index ff6f9d5..a0c5559 100644
--- a/dashboard/src/lib/useAgentModels.js
+++ b/dashboard/src/lib/useAgentModels.js
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { MODEL_OPTIONS, CODEX_MODEL_OPTIONS, CURSOR_MODEL_OPTIONS } from './constants'
+import { MODEL_OPTIONS, CODEX_MODEL_OPTIONS, CURSOR_MODEL_OPTIONS, PI_MODEL_OPTIONS } from './constants'
/**
* Fetches available models for the given agent from the server, which queries
@@ -9,6 +9,7 @@ import { MODEL_OPTIONS, CODEX_MODEL_OPTIONS, CURSOR_MODEL_OPTIONS } from './cons
export function useAgentModels(agent) {
const fallback = agent === 'codex' ? CODEX_MODEL_OPTIONS
: agent === 'cursor' ? CURSOR_MODEL_OPTIONS
+ : agent === 'pi' ? PI_MODEL_OPTIONS
: MODEL_OPTIONS
const [models, setModels] = useState(fallback)
diff --git a/dashboard/src/lib/useAppNavigation.js b/dashboard/src/lib/useAppNavigation.js
index 7e27deb..8b43978 100644
--- a/dashboard/src/lib/useAppNavigation.js
+++ b/dashboard/src/lib/useAppNavigation.js
@@ -22,10 +22,12 @@ export function useAppNavigation() {
? decodeURIComponent(segments[1])
: null
- // drillDownLoopId is derived from URL: /loops/:loopType/:timestamp
- const drillDownLoopId = segments[0] === 'loops' && segments[1] && segments[2]
- ? `${decodeURIComponent(segments[1])}/${decodeURIComponent(segments[2])}`
- : null
+ // drillDownLoopId is derived from URL: /loops/:loopType/:timestamp or /loops/session/:sessionId
+ const drillDownLoopId = segments[0] === 'loops' && segments[1] === 'session' && segments[2]
+ ? `session:${decodeURIComponent(segments[2])}`
+ : segments[0] === 'loops' && segments[1] && segments[2]
+ ? `${decodeURIComponent(segments[1])}/${decodeURIComponent(segments[2])}`
+ : null
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
@@ -51,6 +53,10 @@ export function useAppNavigation() {
navigate(`/loops/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts.slice(1).join('/'))}`)
}, [navigate])
+ const openLoopBySession = useCallback((sessionId) => {
+ navigate(`/loops/session/${encodeURIComponent(sessionId)}`)
+ }, [navigate])
+
const openDispatch = useCallback(() => {
navigate('/dispatch')
}, [navigate])
@@ -78,6 +84,7 @@ export function useAppNavigation() {
handleNavChange,
openJobDetail,
openLoopDetail,
+ openLoopBySession,
openDispatch,
closeJobDetail,
}
diff --git a/dashboard/src/lib/useSessionStore.js b/dashboard/src/lib/useSessionStore.js
index 36428de..c1cce36 100644
--- a/dashboard/src/lib/useSessionStore.js
+++ b/dashboard/src/lib/useSessionStore.js
@@ -114,6 +114,7 @@ export function useSessionStore() {
extraFlags: dispatchOpts.extraFlags || undefined,
plainOutput: dispatchOpts.plainOutput || undefined,
planSlug: dispatchOpts.planSlug || undefined,
+ previousJobId: dispatchOpts.previousJobId || undefined,
skills: Array.isArray(dispatchOpts.skills) ? dispatchOpts.skills : undefined,
}),
})
@@ -173,6 +174,7 @@ export function useSessionStore() {
next.set(sessionId, {
taskText: data.taskText || '',
repoName: data.repo || '',
+ agent: data.agent || 'claude',
jobFile: data.jobFile || null,
created: Date.now(),
ptySessionId: sessionId,
diff --git a/dashboard/src/lib/useSettings.js b/dashboard/src/lib/useSettings.js
index 03ed40b..e4bdb11 100644
--- a/dashboard/src/lib/useSettings.js
+++ b/dashboard/src/lib/useSettings.js
@@ -6,6 +6,8 @@ const DEFAULT_SETTINGS = {
agents: {
claude: { defaultModel: 'claude-opus-4-6', defaultMaxTurns: 10, skipPermissions: false, tuiMode: true, extraFlags: '' },
codex: { defaultModel: 'gpt-5.4', defaultMaxTurns: null, skipPermissions: false, tuiMode: false, extraFlags: '' },
+ cursor: { defaultModel: 'claude-4.6-opus-high-thinking', defaultMaxTurns: null, skipPermissions: false, tuiMode: true, extraFlags: '' },
+ pi: { defaultModel: 'google/gemini-2.5-pro', defaultMaxTurns: null, skipPermissions: false, tuiMode: true, extraFlags: '' },
},
}
@@ -23,6 +25,8 @@ function loadSettings() {
...(parsed.agents || {}),
claude: { ...DEFAULT_SETTINGS.agents.claude, ...(parsed.agents?.claude || {}) },
codex: { ...DEFAULT_SETTINGS.agents.codex, ...(parsed.agents?.codex || {}) },
+ cursor: { ...DEFAULT_SETTINGS.agents.cursor, ...(parsed.agents?.cursor || {}) },
+ pi: { ...DEFAULT_SETTINGS.agents.pi, ...(parsed.agents?.pi || {}) },
},
}
} catch {
diff --git a/dashboard/src/lib/useTerminal.js b/dashboard/src/lib/useTerminal.js
index 43f14ee..96a3394 100644
--- a/dashboard/src/lib/useTerminal.js
+++ b/dashboard/src/lib/useTerminal.js
@@ -49,6 +49,8 @@ export function useTerminal({ onConnected, onIncomingData, onJobsChanged, repo,
if (repo) params.set('repo', repo)
if (sessionId) params.set('session', sessionId)
if (jobFilePath) params.set('jobFile', jobFilePath)
+ const viteKey = import.meta.env.VITE_DISPATCH_API_KEY
+ if (viteKey) params.set('apiKey', viteKey)
const qs = params.toString() ? `?${params.toString()}` : ''
const ws = new WebSocket(`${protocol}//${location.host}/ws/terminal${qs}`)
wsRef.current = ws
diff --git a/dashboard/src/lib/workerUtils.js b/dashboard/src/lib/workerUtils.js
index 8afd1d0..0c34ac4 100644
--- a/dashboard/src/lib/workerUtils.js
+++ b/dashboard/src/lib/workerUtils.js
@@ -10,9 +10,9 @@ export function getRepoFlags(repoName, jobAgents, activeWorkers) {
for (const agent of jobAgents) {
if (agent.repo !== repoName) continue
- if (agent.validation === 'needs_validation') hasReview = true
+ if (agent.validation === 'needs_validation' || agent.status === 'stopped') hasReview = true
if (agent.status === 'in_progress') hasRunning = true
- if (agent.status === 'failed' || agent.status === 'killed') failedCount += 1
+ if (agent.status === 'failed') failedCount += 1
}
if (activeWorkers) {
@@ -113,7 +113,8 @@ function buildWorkerItemsCore(jobAgents, activeWorkers, jobFileToSession, sessio
repo: session.repo,
agent: session.agent || 'claude',
label: session.label,
- needsReview: session.validation === 'needs_validation',
+ read: linkedAgent?.read === true,
+ needsReview: session.validation === 'needs_validation' || session.status === 'stopped',
status: session.status,
validation: session.validation,
created: session.created,
@@ -123,6 +124,9 @@ function buildWorkerItemsCore(jobAgents, activeWorkers, jobFileToSession, sessio
planSlug: session.planSlug || linkedAgent?.planSlug || null,
planTitle: session.planTitle || linkedAgent?.planTitle || null,
planRepo: session.planRepo || linkedAgent?.planRepo || session.repo || null,
+ lastError: linkedAgent?.lastError || null,
+ lastErrorSubKind: linkedAgent?.lastErrorSubKind || null,
+ errorCount: linkedAgent?.errorCount || 0,
})
}
@@ -141,7 +145,8 @@ function buildWorkerItemsCore(jobAgents, activeWorkers, jobFileToSession, sessio
repo: agent.repo,
agent: agent.agent || 'claude',
label: agent.taskName || agent.id,
- needsReview: agent.validation === 'needs_validation',
+ read: agent.read === true,
+ needsReview: agent.validation === 'needs_validation' || agent.status === 'stopped',
status: agent.status,
validation: agent.validation,
created: agent.started,
@@ -153,6 +158,9 @@ function buildWorkerItemsCore(jobAgents, activeWorkers, jobFileToSession, sessio
planSlug: agent.planSlug || null,
planTitle: agent.planTitle || null,
planRepo: agent.planRepo || agent.repo || null,
+ lastError: agent.lastError || null,
+ lastErrorSubKind: agent.lastErrorSubKind || null,
+ errorCount: agent.errorCount || 0,
})
}
diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx
index 0723ab7..3964f4f 100644
--- a/dashboard/src/main.jsx
+++ b/dashboard/src/main.jsx
@@ -1,3 +1,4 @@
+import './lib/apiFetchSetup.js'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
diff --git a/dashboard/src/styles/theme.css b/dashboard/src/styles/theme.css
index a0c6bb5..cce59a7 100644
--- a/dashboard/src/styles/theme.css
+++ b/dashboard/src/styles/theme.css
@@ -18,8 +18,10 @@
--primary-foreground: #1a1b1e;
/* Surface */
- --secondary: #242329;
- --secondary-foreground: #838490;
+ --secondary: #25242a;
+ --secondary-foreground: #a3a4ad;
+ --tertiary: #1b1c20;
+ --tertiary-foreground: #7a7b86;
--muted: #28272e;
--muted-foreground: #6b6c78;
--accent: #28272e;
@@ -79,6 +81,8 @@
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
+ --color-tertiary: var(--tertiary);
+ --color-tertiary-foreground: var(--tertiary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js
index 599915c..1c4db6c 100644
--- a/dashboard/vite.config.js
+++ b/dashboard/vite.config.js
@@ -1,4 +1,4 @@
-import { defineConfig, createLogger } from 'vite'
+import { defineConfig, createLogger, loadEnv } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
@@ -9,20 +9,35 @@ logger.error = (msg, opts) => {
originalError(msg, opts)
}
-export default defineConfig({
- plugins: [react(), tailwindcss()],
- customLogger: logger,
- server: {
- proxy: {
- '/api': {
- target: 'http://localhost:3747',
- configure: (proxy) => { proxy.on('error', () => {}) },
- },
- '/ws': {
- target: 'ws://localhost:3747',
- ws: true,
- configure: (proxy) => { proxy.on('error', () => {}) },
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const dispatchApiKey = env.DISPATCH_API_KEY || ''
+
+ return {
+ plugins: [react(), tailwindcss()],
+ customLogger: logger,
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3747',
+ configure: (proxy) => {
+ proxy.on('proxyReq', (proxyReq) => {
+ if (dispatchApiKey) proxyReq.setHeader('X-API-Key', dispatchApiKey)
+ })
+ proxy.on('error', () => {})
+ },
+ },
+ '/ws': {
+ target: 'ws://localhost:3747',
+ ws: true,
+ configure: (proxy) => {
+ proxy.on('proxyReqWs', (proxyReq) => {
+ if (dispatchApiKey) proxyReq.setHeader('X-API-Key', dispatchApiKey)
+ })
+ proxy.on('error', () => {})
+ },
+ },
},
},
- },
+ }
})
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 0e9a012..1df4110 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -14,13 +14,14 @@
| Topic | File | Summary |
|-------|------|---------|
| CLI & parsers | `docs/cli-architecture.md` | parsers.js job/swarm compatibility exports, cli.js command map, terminal.js structure, task numbering, and data flow |
-| Web dashboard | `docs/dashboard-architecture.md` | Express endpoints for jobs/sessions/schedules, runtime state in `.hub-runtime/`, React views, hooks, PTY lifecycle, and Claude resume flow |
+| Web dashboard | `docs/dashboard-architecture.md` | Express endpoints for jobs/sessions/schedules, runtime state in `.dispatch/runtime/`, React views, hooks, PTY lifecycle, and Claude resume flow |
## Agent Integration
| Topic | File | Summary |
|-------|------|---------|
-| Server API reference | `docs/agent-api.md` | All REST endpoints on port 3747 — jobs, tasks, bugs, plans, checkpoints, schedules, and the hook callback used by `hub-stop.js`. Includes dispatch field reference and injected env vars. |
+| Server API reference | `docs/agent-api.md` | All REST endpoints on port 3747 — optional `DISPATCH_API_KEY` / `DISPATCH_BIND`, `GET /api/catalog`, jobs, tasks, bugs, plans, checkpoints, schedules, and the hook callback used by `hub-stop.js`. Includes dispatch field reference and injected env vars. |
+| Dispatch API quick reference | `docs/dispatch-api-quickref.md` | Minimal handoff for external orchestrators: auth, catalog, dispatch, polling, lifecycle — points to `agent-api.md` for full detail. |
## Standards
diff --git a/docs/agent-api.md b/docs/agent-api.md
index 7f5b8e0..30b8058 100644
--- a/docs/agent-api.md
+++ b/docs/agent-api.md
@@ -1,11 +1,30 @@
# Dispatch Server API — Agent Reference
-Base URL: `http://127.0.0.1:3747`
+Base URL: `http://127.0.0.1:3747` (or your hub host when `DISPATCH_BIND` exposes the LAN).
All request and response bodies are JSON. All write endpoints require `Content-Type: application/json`.
---
+## Authentication and server options
+
+When **`DISPATCH_API_KEY`** is set on the hub process, every **`/api/*`** request must include that key:
+
+- Header `Authorization: Bearer `, or
+- Header `X-API-Key: `
+
+The **`/ws/terminal`** WebSocket accepts the same key via header (`X-API-Key` / `Authorization`, e.g. Vite dev proxy) or query parameter **`apiKey=`** (e.g. browser connecting straight to the hub).
+
+| Env | Purpose |
+|-----|---------|
+| `DISPATCH_API_KEY` | If set, required for all HTTP `/api/*` and for `/ws/terminal` as above. |
+| `DISPATCH_BIND` | Listen address (default `127.0.0.1`). Use `0.0.0.0` only with a firewall + API key on untrusted networks. |
+| `PORT` | HTTP port (default `3747`). |
+
+For **`yarn dev`**, put `DISPATCH_API_KEY` in `dashboard/.env.local` so the Vite proxy forwards it. For a **production build** served by the hub with auth enabled, set **`VITE_DISPATCH_API_KEY`** at build time to the same value (see `dashboard/env.example`).
+
+---
+
## Read Endpoints
### Hub overview
@@ -69,10 +88,12 @@ Returns all jobs across all repos.
}
```
-**Job status values:** `in_progress` | `completed` | `failed` | `killed`
+**Job status values:** `in_progress` | `completed` | `failed` | `stopped`
**Validation values:** `none` | `needs_validation` | `validated` | `rejected`
**runState values:** `queued` | `starting` | `running` | `stopping` | `awaiting_validation` | `validated` | `rejected` | `failed` | `killed`
+`runState` still uses `killed` internally for process termination, but the job's markdown/API status is exposed as `stopped` so the work remains reviewable.
+
### Single job
```
GET /api/jobs/:id
@@ -134,10 +155,37 @@ Returns available skills.
}
```
+### Catalog (repos + agents + models)
+```
+GET /api/catalog
+```
+Single response for orchestrators: connected **repos** (name, relative `path`, task/activity/bugs files), supported **agent** kinds with labels, per-agent **models** (same logic as `/api/agents/models`), and **`modelSources`** (`api`, `fallback`, `codex-cache`, etc.).
+
+```json
+{
+ "hubRoot": ".",
+ "repos": [
+ { "name": "my-app", "path": "../my-app", "taskFile": "todo.md", "activityFile": "activity-log.md", "bugsFile": "bugs.md" }
+ ],
+ "agents": [
+ { "id": "claude", "label": "Claude" },
+ { "id": "codex", "label": "Codex" },
+ { "id": "cursor", "label": "Cursor" }
+ ],
+ "models": {
+ "claude": [{ "value": "claude-opus-4-6", "label": "..." }],
+ "codex": [],
+ "cursor": []
+ },
+ "modelSources": { "claude": "api", "codex": "codex-cache", "cursor": "fallback" }
+}
+```
+
### Models
```
GET /api/agents/models?agent=claude
GET /api/agents/models?agent=codex
+GET /api/agents/models?agent=cursor
```
Returns available model list. Falls back to hardcoded defaults if API is unavailable.
@@ -166,10 +214,10 @@ POST /api/jobs/init
| `repo` | string | yes | Repo name (from config) |
| `taskText` | string | yes | The prompt sent to the agent |
| `originalTask` | string | no | Human-readable task label (shown in UI) |
-| `agent` | string | no | `"claude"` (default) or `"codex"` |
+| `agent` | string | no | `"claude"` (default), `"codex"`, or `"cursor"` |
| `model` | string | no | Model ID e.g. `"claude-opus-4-6"` |
| `maxTurns` | number | no | Max agent turns |
-| `skipPermissions` | boolean | no | Pass `--dangerously-skip-permissions` |
+| `skipPermissions` | boolean | no | Pass the provider-specific permission-bypass flag |
| `plainOutput` | boolean | no | Run with `-p --output-format text` (captures stdout) |
| `useWorktree` | boolean | no | Create an isolated git worktree for this job |
| `baseBranch` | string | no | Base branch for worktree (default: `main`) |
@@ -283,7 +331,7 @@ PUT /api/plans/:repoName/:slug
Body: { "content": "# Plan title\n..." }
POST /api/plans/:repoName/:slug/status
-Body: { "status": "ready" } ← omit or null to clear
+Body: { "status": "ready" | "completed" } ← omit or null to clear
```
---
diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md
index a46647b..378b7fb 100644
--- a/docs/cli-architecture.md
+++ b/docs/cli-architecture.md
@@ -46,7 +46,7 @@ Primary naming has moved from "swarm" to "job". The parser exports the new `pars
| `writeTaskEdit(filePath, taskNum, newText)` | Replaces the text of the Nth open task | `todo.md` |
| `writeTaskMove(sourceFile, taskNum, destFile, section)` | Removes task from source, adds to destination | Two `todo.md` files |
| `writeJobValidation(filePath, status, notes)` | Sets `Validation:` line, appends `## Validation Notes` | Job `.md` file |
-| `writeJobKill(filePath)` | Sets `Status: Killed`, writes `.kill` marker file | Job `.md` file |
+| `writeJobKill(filePath)` | Sets `Status: Stopped`, marks the job for review, writes `.kill` marker file | Job `.md` file |
| `writeJobStatus(filePath, newStatus)` | Updates the `Status:` line to a new value | Job `.md` file |
| `writeSwarmValidation(filePath, status, notes)` | Legacy alias of `writeJobValidation` | Job `.md` file |
| `writeSwarmKill(filePath)` | Legacy alias of `writeJobKill` | Job `.md` file |
diff --git a/docs/coding-standards.md b/docs/coding-standards.md
index d40cce3..2c526d5 100644
--- a/docs/coding-standards.md
+++ b/docs/coding-standards.md
@@ -159,7 +159,8 @@ hub/
styles/ # CSS (Tailwind + theme)
vite.config.js # Build configuration
package.json # Dashboard-only dependencies
- .hub-runtime/ # Runtime state: job-runs.json, staged prompts, event logs
+ .dispatch/
+ runtime/ # Runtime state: job-runs.json, staged prompts, event logs
plans/ # Markdown planning documents
notes/
jobs/ # Primary job progress files (YYYY-MM-DD-slug.md)
@@ -400,7 +401,7 @@ Header fields (parsed from line-level patterns, not YAML frontmatter):
- `# Job Task: ` — Preferred task title header.
- `# Swarm Task: ` — Legacy header still accepted by parsers.
- `Started: ` — Local-time timestamp, no timezone suffix (e.g. `2026-03-31 16:39:56`). **See Timestamp Rules below.**
-- `Status: ` — Normalized to: `in_progress`, `completed`, `failed`, or the raw lowercase value.
+- `Status: ` — Normalized to: `in_progress`, `completed`, `failed`, `stopped` (including legacy `Killed`), or the raw lowercase value.
- `Validation: ` — Optional. Values like `needs_validation`, `validated`, `rejected`.
- `Session: ` — PTY session ID used by the dashboard to reconnect/resume.
- `SkipPermissions: ` — Whether Claude was launched with `--dangerously-skip-permissions`.
diff --git a/docs/dashboard-architecture.md b/docs/dashboard-architecture.md
index 7546b62..9706c4b 100644
--- a/docs/dashboard-architecture.md
+++ b/docs/dashboard-architecture.md
@@ -87,7 +87,7 @@ dashboard/
ESM module. Bridges to `parsers.js` (CommonJS) via `createRequire`.
-The dashboard is the canonical job runtime. It reads both `notes/jobs/` and legacy `notes/swarm/`, stores run state in `.hub-runtime/job-runs.json`, stages prompt files in `.hub-runtime/prompts/`, and stores terminal event output in `.hub-runtime/events/`.
+The dashboard is the canonical job runtime. It reads both `notes/jobs/` and legacy `notes/swarm/`, stores run state in `.dispatch/runtime/job-runs.json`, stages prompt files in `.dispatch/runtime/prompts/`, and stores terminal event output in `.dispatch/runtime/events/`.
### REST API Endpoints
@@ -96,6 +96,7 @@ The dashboard is the canonical job runtime. It reads both `notes/jobs/` and lega
| Endpoint | parsers.js Functions Used |
|----------|--------------------------|
| `GET /api/config` | `loadConfig` |
+| `GET /api/catalog` | `loadConfig` + `getModelsForAgent` (per-agent model lists) — repos, agent kinds, models |
| `GET /api/overview` | `parseTaskFile`, `parseActivityLog`, `getGitInfo`, `listCheckpoints` |
| `GET /api/jobs` and legacy `GET /api/swarm` | `parseJobDir` |
| `GET /api/jobs/:id` and legacy `GET /api/swarm/:id` | `parseJobFile` |
@@ -106,7 +107,7 @@ The dashboard is the canonical job runtime. It reads both `notes/jobs/` and lega
| `GET /api/repos/:name/checkpoints` | `listCheckpoints` |
| `GET /api/schedules` | Reads `schedules.json` |
| `GET /api/events/search` | `eventPipeline.searchEvents` |
-| `GET /api/job-runs` | Reads `.hub-runtime/job-runs.json` |
+| `GET /api/job-runs` | Reads `.dispatch/runtime/job-runs.json` |
#### Write Endpoints
@@ -123,7 +124,7 @@ The dashboard is the canonical job runtime. It reads both `notes/jobs/` and lega
| `POST /api/hooks/stop-ready` | None | Stop hook callback that transitions an active run to review-ready |
| `POST /api/jobs/:id/validate` and legacy `POST /api/swarm/:id/validate` | `writeJobValidation` | Set validation to "validated" and finalize run state |
| `POST /api/jobs/:id/reject` and legacy `POST /api/swarm/:id/reject` | `writeJobValidation` | Set validation to "rejected" |
-| `POST /api/jobs/:id/kill` and legacy `POST /api/swarm/:id/kill` | `writeJobKill` | Mark agent as killed and stop PTY |
+| `POST /api/jobs/:id/kill` and legacy `POST /api/swarm/:id/kill` | `writeJobKill` | Stop the agent, mark the job `stopped`, and stop the PTY |
| `POST /api/jobs/:id/merge` and legacy `POST /api/swarm/:id/merge` | None (git operations) | Merge agent branch into target |
| `DELETE /api/jobs/:id` and legacy `DELETE /api/swarm/:id` | None (fs unlink) | Delete job file and remove run history |
| `POST /api/repos/:name/checkpoint` | `createCheckpoint` |
@@ -174,7 +175,7 @@ Captures and structures terminal session output into queryable events.
1. **Line classification** — Categorizes terminal output lines as: error, warning, progress, tool, file, thought, action
2. **Agent detection** — Identifies session agent kind (claude, codex, generic)
-3. **Event persistence** — Writes NDJSON files to `.hub-runtime/events/.ndjson`
+3. **Event persistence** — Writes NDJSON files to `.dispatch/runtime/events/.ndjson`
4. **Coalescing** — Deduplicates and merges related output lines
5. **Summary tracking** — Maintains per-session stats: last step, errors, files touched, tool calls
6. **Search** — Full-text search across session event history
@@ -247,7 +248,7 @@ import { repoIdentityColors } from '../lib/constants'
**Worker list building** — `lib/workerUtils.js` provides `buildWorkerNavItems()` which unifies active PTY sessions with swarm agents and validation states. Used by ActivityBar, JobsView, and App for badge counts.
**Status config** — In `lib/statusConfig.js`:
-Maps status strings (`in_progress`, `completed`, `failed`, `killed`, `needs_validation`) to `{ icon, color, bg, label, dotColor }`.
+Maps status strings (`in_progress`, `completed`, `failed`, `stopped`, `needs_validation`) to `{ icon, color, bg, label, dotColor }`.
**Markdown rendering** — Shared `mdComponents` in `components/mdComponents.jsx` for consistent react-markdown styling.
@@ -314,7 +315,7 @@ The `agentTerminals` Map bridges these: each entry stores `{ jobFile: { fileName
7. The first resize event triggers `startPendingLaunch()`, which writes a tracked Claude command into the PTY. For new work this is `claude [flags] "$(cat promptFile)"`; for resumes it is `claude [flags] --resume ""`
8. Terminal output is captured by `eventPipeline.ingestLine()` for structured event storage and by the server's resume-command detector
9. When Claude prints a resume command, the server persists normalized `ResumeCommand`, `ResumeId`, and `SkipPermissions` into the job file
-10. On PTY exit, the run transitions to review/failed/killed state and the job markdown is updated accordingly
+10. On PTY exit, the run transitions to review/failed/killed state and the job markdown is updated accordingly; user-stopped jobs are written back as `Status: Stopped` so they remain reviewable
11. If the user clicks Resume later, `POST /api/jobs/:id/resume` recreates the PTY under the same tracked session ID, replays prior scrollback, and relaunches Claude with the stored flags
---
diff --git a/docs/dispatch-api-quickref.md b/docs/dispatch-api-quickref.md
new file mode 100644
index 0000000..a57774d
--- /dev/null
+++ b/docs/dispatch-api-quickref.md
@@ -0,0 +1,144 @@
+# Dispatch Hub API — minimal reference (for orchestrators)
+
+Use this when integrating **another service or agent** (e.g. Darby) with the Dispatch dashboard server. For full detail, see [`agent-api.md`](./agent-api.md).
+
+**Default base URL:** `http://127.0.0.1:3747` — replace host/port if the hub binds elsewhere (`PORT`, `DISPATCH_BIND`).
+
+---
+
+## Auth (when enabled)
+
+If the hub sets **`DISPATCH_API_KEY`**, send the same secret on **every** HTTP call to `/api/*`:
+
+| Header | Value |
+|--------|--------|
+| `X-API-Key` | `` |
+| *or* `Authorization` | `Bearer ` |
+
+`Content-Type: application/json` is required for POST/PUT bodies.
+
+Unauthorized requests return **`401`** with `{ "error": "..." }`.
+
+---
+
+## 1. Discover repos, agents, and models
+
+```http
+GET /api/catalog
+```
+
+Returns JSON including:
+
+- **`repos`** — `name`, relative `path`, `taskFile`, `activityFile`, optional `bugsFile` (use **`name`** as `repo` when dispatching).
+- **`agents`** — supported kinds: `claude`, `codex`, `cursor`.
+- **`models`** — per-agent model lists (`value` / `label`).
+- **`modelSources`** — how each list was resolved (`api`, `fallback`, `codex-cache`, …).
+- **`hubRoot`** — hub root path hint.
+
+---
+
+## 2. Start a job
+
+```http
+POST /api/jobs/init
+```
+
+**Body (minimum):**
+
+```json
+{
+ "repo": "",
+ "taskText": ""
+}
+```
+
+**Common optional fields:** `originalTask`, `agent` (`claude` default), `model`, `maxTurns`, `skipPermissions`, `useWorktree`, `baseBranch`, `autoMerge`, `planSlug`, `skills` (string array), `sessionId`, `extraFlags`.
+
+**Response (typical):**
+
+```json
+{
+ "fileName": "2026-04-02-slug.md",
+ "relativePath": "notes/jobs/2026-04-02-slug.md",
+ "absolutePath": "/…",
+ "repo": "my-app",
+ "sessionId": "session-",
+ "serverStarted": true,
+ "branch": "job/2026-04-02-slug",
+ "worktreePath": "/…"
+}
+```
+
+Save **`sessionId`** and the job **`id`** (the slug, e.g. `2026-04-02-slug`, from `fileName` without `.md` or from `GET /api/jobs`).
+
+Legacy alias: `POST /api/swarm/init` (same body).
+
+---
+
+## 3. Follow progress (polling)
+
+There is **no** server-sent job stream for external clients. **Poll** on an interval (e.g. 1–3s).
+
+**While the session exists in memory:**
+
+```http
+GET /api/sessions//events?limit=120&cursor=
+```
+
+Response: `{ "sessionId", "items": [...], "nextCursor" }` — pass **`nextCursor`** on the next request until null.
+
+Optional rollup:
+
+```http
+GET /api/sessions//summary
+```
+
+**If the hub restarts** or the session is gone, session endpoints may return **`404`**. Fall back to:
+
+```http
+GET /api/jobs/
+```
+
+Parse status / validation from the job payload until the job reaches a terminal state (e.g. completed + needs review, failed, stopped).
+
+---
+
+## 4. Job actions (after the agent stops)
+
+| Action | Request |
+|--------|---------|
+| Approve | `POST /api/jobs//validate` — body `{ "notes": "optional" }` |
+| Reject | `POST /api/jobs//reject` — body `{ "notes": "required" }` |
+| Stop | `POST /api/jobs//kill` |
+| Merge branch | `POST /api/jobs//merge` — body `{ "targetBranch": "main" }` optional |
+| Delete job file | `DELETE /api/jobs/` |
+
+`/api/swarm/...` paths mirror `/api/jobs/...` for compatibility.
+
+---
+
+## 5. Errors
+
+```json
+{ "error": "Human-readable message" }
+```
+
+Typical: **`400`** invalid input, **`401`** auth, **`404`** missing resource, **`409`** bad state transition, **`500`** server error.
+
+---
+
+## 6. Optional: stop hook (Claude Code)
+
+If the repo uses Dispatch’s Claude hook, completion may call:
+
+```http
+POST /api/hooks/stop-ready
+```
+
+Body: `{ "sessionId", "jobId", "reason" }` — usually not called by external orchestrators; listed for completeness.
+
+---
+
+## 7. WebSocket (terminal attach only)
+
+`GET /ws/terminal?repo=...&session=...&jobFile=...` — interactive PTY for the dashboard, **not** required for HTTP-only orchestration. If `DISPATCH_API_KEY` is set, pass the key via **`X-API-Key`** on the upgrade or **`?apiKey=`** (browser limitation).
diff --git a/loops/parallel-review.sh b/loops/parallel-review.sh
index 7691039..5164295 100755
--- a/loops/parallel-review.sh
+++ b/loops/parallel-review.sh
@@ -19,7 +19,7 @@
# gpt-5.2-codex gpt-5.2 gpt-5.1-codex-max
# gpt-5.1-codex-mini
#
-# cursor (agent -p — run `agent --list-models` for latest)
+# cursor (cursor-agent or agent -p — run `agent --list-models` for latest)
# Cursor Composer
# auto composer-2 composer-2-fast composer-1.5
# Claude
@@ -159,7 +159,8 @@ fi
# ---------------------------------------------------------------------------
run_agent() {
local spec="$1"; shift
- local extra_flags=("$@")
+ local extra_flags=()
+ [[ $# -gt 0 ]] && extra_flags=("$@")
# Parse tool and optional model from spec; fall back to DEFAULT_*_MODEL
local tool model=""
@@ -209,10 +210,19 @@ run_agent() {
fi
;;
cursor)
- # Cursor CLI doesn't read stdin — prompt goes as a positional argument
+ # Cursor CLI doesn't read stdin — prompt goes as a positional argument.
+ # Prefer cursor-agent to match server launch/resume paths, but fall back
+ # to agent if that's the installed binary.
local prompt_content
prompt_content=$(cat "$tmp_prompt")
- local cmd=(agent -p "$prompt_content")
+ local cursor_bin="cursor-agent"
+ local cmd=()
+ if ! command -v "$cursor_bin" >/dev/null 2>&1; then
+ cursor_bin="agent"
+ cmd=("$cursor_bin" -p "$prompt_content")
+ else
+ cmd=("$cursor_bin" "$prompt_content")
+ fi
[[ -n "$model" ]] && cmd+=(--model "$model")
for flag in "${extra_flags[@]}"; do
if [[ "$flag" == "--dangerously-skip-permissions" ]]; then
diff --git a/parsers.integration.test.js b/parsers.integration.test.js
index 0633a28..bae07e8 100644
--- a/parsers.integration.test.js
+++ b/parsers.integration.test.js
@@ -247,7 +247,8 @@ describe('normalizeStatus via parseJobFile', () => {
['Completed', 'completed'],
['In progress', 'in_progress'],
['Failed', 'failed'],
- ['Killed', 'killed'],
+ ['Killed', 'stopped'],
+ ['Stopped', 'stopped'],
];
for (const [input, expected] of cases) {
const p = makeJob(input);
diff --git a/parsers.js b/parsers.js
index 2822e99..871d150 100644
--- a/parsers.js
+++ b/parsers.js
@@ -113,7 +113,7 @@ function normalizeStatus(raw) {
if (lower === 'complete' || lower === 'completed') return 'completed';
if (lower === 'in progress' || lower === 'in_progress') return 'in_progress';
if (lower === 'failed') return 'failed';
- if (lower === 'killed') return 'killed';
+ if (lower === 'killed' || lower === 'stopped') return 'stopped';
return lower;
}
@@ -122,8 +122,10 @@ function parseJobFile(filePath) {
let taskName = '', started = '', status = 'unknown', validation = 'none', agentId = null, skills = [];
let originalTask = '', session = null, repo = null;
let originalPrompt = '';
+ let previousJobId = null, nextJobId = null;
let planSlug = null;
let agent = 'claude';
+ let read = false;
let skipPermissions = null;
let resumeId = null, resumeCommand = null;
let ai = null;
@@ -170,9 +172,22 @@ function parseJobFile(filePath) {
const sessionMatch = line.match(/^Session:\s*(.+)/);
if (sessionMatch) { session = sessionMatch[1].trim(); continue; }
+ const previousJobMatch = line.match(/^PreviousJob:\s*(.+)/);
+ if (previousJobMatch) { previousJobId = previousJobMatch[1].trim(); continue; }
+
+ const nextJobMatch = line.match(/^NextJob:\s*(.+)/);
+ if (nextJobMatch) { nextJobId = nextJobMatch[1].trim(); continue; }
+
const agentMatch = line.match(/^Agent:\s*(.+)/);
if (agentMatch) { agent = agentMatch[1].trim().toLowerCase() || 'claude'; continue; }
+ const readMatch = line.match(/^Read:\s*(.+)/);
+ if (readMatch) {
+ const raw = readMatch[1].trim().toLowerCase();
+ read = raw === 'true' || raw === 'yes' || raw === '1';
+ continue;
+ }
+
const skipPermissionsMatch = line.match(/^SkipPermissions:\s*(.+)/);
if (skipPermissionsMatch) {
const raw = skipPermissionsMatch[1].trim().toLowerCase();
@@ -249,7 +264,8 @@ function parseJobFile(filePath) {
return {
id, taskName, started, status, validation, agentId, skills,
agent,
- originalTask, originalPrompt, session, skipPermissions, resumeId, resumeCommand, repo, planSlug,
+ read,
+ originalTask, originalPrompt, session, previousJobId, nextJobId, skipPermissions, resumeId, resumeCommand, repo, planSlug,
branch, worktreePath, startCommit, baseBranch,
type, loopType,
lastProgress, progressCount, durationMinutes, resultsSummary,
@@ -335,6 +351,97 @@ function parseLoopState(loopDir) {
return { iteration, lastVerdict, complete, runDir };
}
+function parseLoopRunDetailed(runDir) {
+ const run = parseLoopRun(runDir);
+ const iterations = [];
+ const warnings = [];
+ const addWarning = (context, err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ warnings.push(`${context}: ${message}`);
+ };
+ // Parse iteration info from loop.log body
+ try {
+ const logPath = path.join(runDir, 'loop.log');
+ const content = fs.readFileSync(logPath, 'utf8');
+ const lines = content.split('\n');
+ let pastHeader = false;
+ let currentIter = null;
+ for (const line of lines) {
+ if (!pastHeader) {
+ if (line.trim() === '---') pastHeader = true;
+ continue;
+ }
+ const iterMatch = line.match(/Iteration\s+(\d+)\s*[-–—]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/i);
+ if (iterMatch) {
+ currentIter = { number: parseInt(iterMatch[1], 10), timestamp: iterMatch[2], verdict: null };
+ iterations.push(currentIter);
+ continue;
+ }
+ // Also match iteration lines without timestamps
+ const iterMatchSimple = line.match(/Iteration\s+(\d+)/i);
+ if (iterMatchSimple && !iterMatch) {
+ const num = parseInt(iterMatchSimple[1], 10);
+ if (!currentIter || currentIter.number !== num) {
+ currentIter = { number: num, timestamp: null, verdict: null };
+ iterations.push(currentIter);
+ }
+ }
+ if (currentIter) {
+ if (line.includes('VERDICT: PASS') || line.includes('VERIFIED: PASS')) currentIter.verdict = 'PASS';
+ else if (line.includes('VERDICT: FAIL')) currentIter.verdict = 'FAIL';
+ else if (line.includes('ALL PHASES COMPLETE')) currentIter.verdict = 'PASS';
+ else if (line.includes('ALL ISSUES RESOLVED')) currentIter.verdict = 'PASS';
+ }
+ }
+ } catch (err) {
+ addWarning('failed to parse loop.log', err);
+ }
+
+ // Scan for artifact files
+ const artifacts = [];
+ try {
+ const files = fs.readdirSync(runDir).filter(f => f.endsWith('.txt')).sort();
+ for (const file of files) {
+ let type = 'unknown', iteration = null;
+ const reviewMatch = file.match(/^review_iter(\d+)/);
+ const synthMatch = file.match(/^synthesis_iter(\d+)/);
+ const verifyMatch = file.match(/^verification_iter(\d+)/);
+ if (reviewMatch) { type = 'review'; iteration = parseInt(reviewMatch[1], 10); }
+ else if (synthMatch) { type = 'synthesis'; iteration = parseInt(synthMatch[1], 10); }
+ else if (verifyMatch) { type = 'verification'; iteration = parseInt(verifyMatch[1], 10); }
+
+ let content = '';
+ try {
+ content = fs.readFileSync(path.join(runDir, file), 'utf8');
+ } catch (err) {
+ addWarning(`failed to read artifact "${file}"`, err);
+ }
+ artifacts.push({ name: file, type, iteration, content });
+ }
+ } catch (err) {
+ addWarning('failed to scan artifact directory', err);
+ }
+
+ // Read prompt
+ let prompt = '';
+ try {
+ const candidatePromptPaths = [
+ path.join(runDir, '..', 'prompt.md'),
+ path.join(runDir, 'prompt.md'),
+ path.join(path.dirname(path.dirname(runDir)), 'prompt.md'),
+ ];
+ for (const promptPath of candidatePromptPaths) {
+ if (!fs.existsSync(promptPath)) continue;
+ prompt = fs.readFileSync(promptPath, 'utf8');
+ break;
+ }
+ } catch (err) {
+ addWarning('failed to read prompt.md', err);
+ }
+
+ return { ...run, iterations, artifacts, prompt, warnings };
+}
+
function loadConfig(hubDir) {
const localConfigPath = path.join(hubDir, 'config.local.json');
const configPath = fs.existsSync(localConfigPath) ? localConfigPath : path.join(hubDir, 'config.json');
@@ -590,18 +697,22 @@ function writeJobValidation(filePath, newValidation, notes) {
}
/**
- * Kill a swarm agent by marking its file as Killed and writing a .kill marker.
+ * Stop a swarm agent by marking its file as Stopped and writing a .kill marker.
* Returns { success, id, agentId }.
*/
function writeJobKill(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let statusLineIndex = -1;
+ let validationLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
- if (lines[i].match(/^Status:\s/)) {
+ if (statusLineIndex === -1 && lines[i].match(/^Status:\s/)) {
statusLineIndex = i;
- break;
+ continue;
+ }
+ if (validationLineIndex === -1 && lines[i].match(/^Validation:\s/)) {
+ validationLineIndex = i;
}
}
@@ -609,14 +720,24 @@ function writeJobKill(filePath) {
throw new Error('swarm file has no Status: line');
}
- // Change Status to Killed
- lines[statusLineIndex] = 'Status: Killed';
+ // Change Status to Stopped so the work can still be reviewed/followed up.
+ lines[statusLineIndex] = 'Status: Stopped';
+
+ // Ensure stopped work is surfaced for review unless it has already been reviewed.
+ if (validationLineIndex !== -1) {
+ const currentVal = lines[validationLineIndex].replace(/^Validation:\s*/, '').trim().toLowerCase().replace(/\s+/g, '_');
+ if (currentVal !== 'validated' && currentVal !== 'rejected' && currentVal !== 'needs_validation') {
+ lines[validationLineIndex] = 'Validation: Needs validation';
+ }
+ } else {
+ lines.splice(statusLineIndex + 1, 0, 'Validation: Needs validation');
+ }
- // Append a ## Killed section with timestamp
+ // Append a ## Stopped section with timestamp.
const killTimestamp = new Date().toISOString();
lines.push('');
- lines.push('## Killed');
- lines.push(`Killed at: ${killTimestamp}`);
+ lines.push('## Stopped');
+ lines.push(`Stopped at: ${killTimestamp}`);
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
@@ -692,7 +813,8 @@ function writeJobStatus(filePath, newStatus) {
completed: 'Completed',
in_progress: 'In progress',
failed: 'Failed',
- killed: 'Killed',
+ stopped: 'Stopped',
+ killed: 'Stopped',
};
const validStatuses = Object.keys(displayMap);
if (!validStatuses.includes(newStatus)) {
@@ -701,10 +823,10 @@ function writeJobStatus(filePath, newStatus) {
const displayValue = displayMap[newStatus];
lines[statusLineIndex] = `Status: ${displayValue}`;
- // When completing, always force validation to "Needs validation".
+ // When completing, and when explicitly stopping a job, surface it for review.
// This prevents agents from self-validating by writing Validation: Validated
// directly to the swarm file before the PTY exits.
- if (newStatus === 'completed') {
+ if (newStatus === 'completed' || newStatus === 'stopped' || newStatus === 'killed') {
let validationLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^Validation:\s/)) {
@@ -714,7 +836,8 @@ function writeJobStatus(filePath, newStatus) {
}
if (validationLineIndex !== -1) {
const currentVal = lines[validationLineIndex].replace(/^Validation:\s*/, '').trim().toLowerCase().replace(/\s+/g, '_');
- if (currentVal !== 'needs_validation') {
+ const shouldForceReview = newStatus === 'completed' || (currentVal !== 'validated' && currentVal !== 'rejected');
+ if (shouldForceReview && currentVal !== 'needs_validation') {
lines[validationLineIndex] = 'Validation: Needs validation';
}
} else {
@@ -726,7 +849,7 @@ function writeJobStatus(filePath, newStatus) {
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
const parsed = parseJobFile(filePath);
- return { success: true, id: parsed.id, status: newStatus };
+ return { success: true, id: parsed.id, status: parsed.status };
}
/**
@@ -1436,6 +1559,7 @@ module.exports = {
dismissCheckpoint,
listCheckpoints,
parseLoopRun,
+ parseLoopRunDetailed,
parseAllLoopRuns,
parseLoopState,
};
diff --git a/parsers.test.js b/parsers.test.js
index 3aa64b3..9534a6c 100644
--- a/parsers.test.js
+++ b/parsers.test.js
@@ -257,6 +257,8 @@ describe('parseJobFile', () => {
'Repo: app',
'OriginalPrompt: Build login page\\nwith tests',
'Session: sess-123',
+ 'PreviousJob: 2026-03-24-discovery',
+ 'NextJob: 2026-03-26-polish',
'SkipPermissions: true',
'ResumeId: resume-456',
'ResumeCommand: claude --resume',
@@ -283,6 +285,8 @@ describe('parseJobFile', () => {
assert.equal(result.repo, 'app');
assert.equal(result.originalPrompt, 'Build login page\\nwith tests');
assert.equal(result.session, 'sess-123');
+ assert.equal(result.previousJobId, '2026-03-24-discovery');
+ assert.equal(result.nextJobId, '2026-03-26-polish');
assert.equal(result.skipPermissions, true);
assert.equal(result.resumeId, 'resume-456');
assert.equal(result.resumeCommand, 'claude --resume');
@@ -314,7 +318,8 @@ describe('parseJobFile', () => {
['In progress', 'in_progress'],
['In_progress', 'in_progress'],
['Failed', 'failed'],
- ['Killed', 'killed'],
+ ['Killed', 'stopped'],
+ ['Stopped', 'stopped'],
];
for (const [raw, expected] of cases) {
const f = tmp(`job-${raw.replace(/\s/g, '')}.md`, [
@@ -359,6 +364,24 @@ describe('parseJobFile', () => {
].join('\n'));
assert.equal(parseJobFile(f).skipPermissions, false);
});
+
+ it('parses Read variations', () => {
+ for (const val of ['true', 'yes', '1']) {
+ const f = tmp(`read-${val}.md`, [
+ '# Job Task: Test',
+ 'Status: Completed',
+ `Read: ${val}`,
+ ].join('\n'));
+ assert.equal(parseJobFile(f).read, true);
+ }
+
+ const f = tmp('read-false.md', [
+ '# Job Task: Test',
+ 'Status: Completed',
+ 'Read: false',
+ ].join('\n'));
+ assert.equal(parseJobFile(f).read, false);
+ });
});
// ── parseJobDir ──────────────────────────────────────────────
@@ -936,7 +959,7 @@ describe('writeJobKill', () => {
beforeEach(setup);
afterEach(teardown);
- it('sets status to Killed and creates .kill marker', () => {
+ it('sets status to Stopped, marks it for review, and creates .kill marker', () => {
const f = tmp('job.md', [
'# Job Task: Test',
'Status: In progress',
@@ -949,8 +972,9 @@ describe('writeJobKill', () => {
assert.equal(result.success, true);
const content = read(f);
- assert.ok(content.includes('Status: Killed'));
- assert.ok(content.includes('## Killed'));
+ assert.ok(content.includes('Status: Stopped'));
+ assert.ok(content.includes('Validation: Needs validation'));
+ assert.ok(content.includes('## Stopped'));
// Kill marker file should exist
assert.ok(fs.existsSync(f + '.kill'));
diff --git a/todo.md b/todo.md
index eb864f2..6536e74 100644
--- a/todo.md
+++ b/todo.md
@@ -5,12 +5,12 @@
## Present
-- [ ] change .hub-runtime to .dispatch and update all references to it.
+- [x] move dashboard runtime state to `.dispatch/runtime/` and update all references.
- [ ] rename hub.root to dispatch.root in config.json, config.local.json, and config.example.json
- [ ] Move tests to a separate directory. Update test command accordingly
- [ ] add skills-lock.json to gitignore
-- [ ] Add multi-dispatch feature for task list. On right, to left of "Start", show a checkbox to select multiple tasks. When at least one is selected hide the "Start" button and show a "Start tasks" button. When no tasks are selected, show the "Start" button. Then on the dispatch page, show each task in the prompt input. We also need a way to track multi-task completion for a single job. Show Mark Task done button for each task, but limit the text to a reasonable length, so we don't have large newlines. Hovering over the task name should show the full task text.
-- [ ] If work is added to the base repo, then worktree diffs show all these changes, since it currently uses the commit it was started from. We need to make sure that the worktree diffs show only the changes for the current job. It would also be nice to show the changes for the current job even without using worktrees, but im not sure how to do that.
+- [x] Add multi-dispatch feature for task list. On right, to left of "Start", show a checkbox to select multiple tasks. When at least one is selected hide the "Start" button and show a "Start tasks" button. When no tasks are selected, show the "Start" button. Then on the dispatch page, show each task in the prompt input. We also need a way to track multi-task completion for a single job. Show Mark Task done button for each task, but limit the text to a reasonable length, so we don't have large newlines. Hovering over the task name should show the full task text.
+- [x] If work is added to the base repo, then worktree diffs show all these changes, since it currently uses the commit it was started from. We need to make sure that the worktree diffs show only the changes for the current job. It would also be nice to show the changes for the current job even without using worktrees, but im not sure how to do that.
## Workflows
@@ -39,7 +39,7 @@
- [x] Review the current code for quality and brevity — *Feedback: reviewer assessed code quality as "Clean, well-structured." Focus review effort on parsers.js edge cases (fuzzy matching, stemming) and dual-state sync patterns rather than broad refactoring.*
- [x] Make `maxTurns` required with sensible defaults; add rough cost-per-job tracking via token counts — *Feedback: the `monthlyBudget` field is purely informational today. Making maxTurns required prevents runaway jobs. Rough cost-per-job via token counts from Claude's output helps users understand spending.*
-- [x] Promote markdown job files as canonical state; derive `.hub-runtime/job-runs.json` as cache to reduce dual-state sync bugs — *Feedback: job state currently lives in markdown (notes/jobs/*.md), run state in job-runs.json, schedules in schedules.json, and config in config.json. A job's Status: line can disagree with run state, creating reconciliation complexity. Markdown should be canonical; JSON should be derived cache.*
+- [x] Promote markdown job files as canonical state; derive `.dispatch/runtime/job-runs.json` as cache to reduce dual-state sync bugs — *Feedback: job state currently lives in markdown (notes/jobs/*.md), run state in job-runs.json, schedules in schedules.json, and config in config.json. A job's Status: line can disagree with run state, creating reconciliation complexity. Markdown should be canonical; JSON should be derived cache.*
- [ ] Add auth to the Express dashboard server (required before any remote/Tailscale access) — *Feedback: port 3001 has no authentication. Anyone who can reach it can read tasks, dispatch jobs, validate/reject work, and access live terminal WebSocket sessions. Fine for strict localhost; mandatory before any remote access (e.g. over Tailscale).*
- [x] Write a product showcase video script for Work Down. I will handle video production using the /remotion skill when you are done. You should focus on the following items: open-source, local-first, lightweight.
- [x] Create `/remove-repo` skill to uninstall hooks and remove config entries (inverse of `/add-repo`) — *Feedback: `/add-repo` copies hooks into other repos' `.claude/` directories and modifies their `settings.json`. If someone stops using Dispatch, those hooks remain orphaned (`hub-stop.js` is a no-op without env vars, but still unexpected). A `/remove-repo` skill should clean up hooks and remove the config entry.*