'use strict'
const $ = (id) => document.getElementById(id)
const state = {
data: null,
logSeeded: false,
lastSeq: 0,
modalIteration: null,
goals: [],
selectedFile: '',
forceLauncher: false,
prevStatus: null,
}
const ACTIVE_STATUSES = new Set(['starting', 'running', 'paused', 'waiting-answer'])
// Top-bar activity indicator: error/warn/problem states tint the bar, active
// states animate it, terminal states settle to a static line.
const ERROR_STATUSES = new Set(['error', 'done-with-errors'])
const WARN_STATUSES = new Set(['aborted', 'stopped-budget', 'stalled', 'stopped-guard'])
const TOPBAR_CLASSES = ['topbar--running', 'topbar--paused', 'topbar--waiting', 'topbar--done', 'topbar--warn', 'topbar--error']
function topbarState(s) {
if (s.idle === true) return ''
if (s.stale || ERROR_STATUSES.has(s.status)) return 'error'
if (WARN_STATUSES.has(s.status)) return 'warn'
if (s.status === 'starting' || s.status === 'running') return 'running'
if (s.status === 'waiting-answer') return 'waiting'
if (s.status === 'paused') return 'paused'
if (s.status === 'done') return 'done'
return ''
}
const fmt = (n) => Number(n || 0).toLocaleString('en-US')
const fmtCost = (n) => {
const v = Number(n || 0)
if (!v) return '$0.0000'
return v < 0.01 ? `$${v.toFixed(5)}` : `$${v.toFixed(4)}`
}
const fmtDur = (ms) => {
const s = Math.max(0, Math.round((ms || 0) / 1000))
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m`
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
const esc = (s) =>
String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
// ---------- stats ----------
function renderStats(s) {
const totals = s.totals || { tokens: {}, cost: 0, durationMs: 0 }
const done = s.iterations.length
const last = s.iterations[s.iterations.length - 1]
const percent = last?.percent ?? 0
const context = last?.contextSize ?? 0
const verify = s.verify || { passed: 0, failed: 0 }
const statuses = s.iterations.map((i) => i.status)
const statusText = s.stale ? `${s.status} · stale` : s.status
const cards = [
{
k: 'Progress',
v: `${percent}%`,
bar: percent,
},
{ k: 'Iteration', v: `${done} / ${s.configuredIterations ?? s.iterations.length}` },
{ k: 'Status', v: `${esc(statusText)}` },
{ k: 'Tokens', v: `${fmt(totals.tokens?.total)} in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}` },
{ k: 'Cost', v: fmtCost(totals.cost) },
{
k: 'Context',
v: `${fmt(context)} tokens`,
},
{ k: 'Verify', v: `${verify.passed || 0} pass · ${verify.failed || 0} fail` },
]
$('stats').innerHTML = cards
.map(
(c) => `
${c.k}
${c.v}
${
c.bar !== undefined ? `
` : ''
}
`,
)
.join('')
void statuses
}
// ---------- stages (kanban strip) ----------
function renderStages(s) {
const box = $('stages')
const stages = s.stages || []
if (!stages.length) {
box.innerHTML = 'No stages in the goal.
'
return
}
const last = s.iterations[s.iterations.length - 1]
const currentId = last?.stageId || null
box.innerHTML = stages
.map((st, i) => {
const isCurrent = st.id === currentId
const cls = st.done ? 'done' : isCurrent ? 'current' : 'pending'
const verify = st.verify ? `verify` : ''
return `
${i + 1}${esc(st.title)}${verify}
`
})
.join('')
const v = s.verify || {}
const hint = $('stages-hint')
if (hint) hint.textContent = `verification gate · ${v.passed || 0} passed · ${v.failed || 0} failed`
}
// ---------- iterations ----------
function renderIterations(s) {
const box = $('iterations')
if (!s.iterations.length) {
box.innerHTML = 'No iterations yet.
'
return
}
box.innerHTML = s.iterations
.map((it) => {
const pct = it.percent ?? 0
const status = it.status || 'pending'
const questions = (it.questions || []).length
? `${it.questions.map((q) => `- ${esc(q.text)}
`).join('')}
`
: ''
const files = (it.filesChanged || []).length
? `${it.filesChanged.slice(0, 8).map((f) => `${esc(f)}`).join('')}${
it.filesChanged.length > 8 ? `+${it.filesChanged.length - 8}` : ''
}
`
: ''
return `
#${it.index}
${esc(it.topic || 'running…')}
${s.sharedContext ? 'shared ctx' : 'fresh ctx'}
${esc(status)}
${fmt(it.tokens?.total)} tok
${fmtCost(it.cost)}
ctx ${fmt(it.contextSize)}
${fmtDur(it.durationMs)}
${it.toolCount ? `${it.toolCount} tools` : ''}
${it.verify ? `verify ${it.verify.ok ? 'pass' : it.verify.timedOut ? 'timeout' : 'FAIL'}` : ''}
${it.attempts > 1 ? `auto-retry ×${it.attempts}` : ''}
${it.guardViolation ? `guard blocked` : ''}
${it.checkpointId ? `cp ${esc(it.checkpointId)}` : ''}
${it.sessionID ? `session ${esc(String(it.sessionID).slice(0, 12))}…` : ''}
Stage: ${esc(it.stageTitle || '—')} [${esc(it.stageStatus || 'n/a')}]
Next: ${esc(it.nextStage || '—')}
${it.summary ? `
${esc(it.summary)}
` : ''}
${files}
${questions}
${it.checkpointId ? `
` : ''}
`
})
.join('')
void box
}
// ---------- log ----------
function logNode(entry) {
const div = document.createElement('div')
div.className = `line ${entry.level || 'info'}`
const time = new Date(entry.t).toLocaleTimeString('en-US', { hour12: false })
const prefix = entry.level === 'section' ? '' : `${time}`
div.innerHTML = prefix + esc(entry.text)
return div
}
function appendLog(entry) {
if (!entry || entry.seq <= state.lastSeq) return
state.lastSeq = entry.seq
const log = $('log')
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60
log.appendChild(logNode(entry))
while (log.childElementCount > 2000) log.removeChild(log.firstChild)
if (nearBottom || entry.level === 'section') log.scrollTop = log.scrollHeight
}
function seedLog(entries) {
const log = $('log')
log.innerHTML = ''
state.lastSeq = 0
for (const e of entries || []) {
state.lastSeq = Math.max(state.lastSeq, e.seq || 0)
log.appendChild(logNode(e))
}
log.scrollTop = log.scrollHeight
}
// ---------- question modal ----------
function renderModal(s) {
const modal = $('modal')
const pq = s.pendingQuestion
if (!pq) {
modal.classList.add('hidden')
state.modalIteration = null
return
}
if (state.modalIteration === pq.iteration) return
state.modalIteration = pq.iteration
const questions = pq.questions.length ? pq.questions : [{ id: 'q1', text: 'Any corrections before the next iteration?', options: [] }]
$('modal-sub').textContent = `Iteration ${pq.iteration} finished. Answers are injected into the next session.`
$('modal-questions').innerHTML = questions
.map(
(q, i) => `
${
q.options?.length
? `
${q.options
.map((o) => ``)
.join('')}
`
: ''
}
`,
)
.join('')
modal.classList.remove('hidden')
const first = $('modal-questions').querySelector('input')
if (first) setTimeout(() => first.focus(), 30)
$('modal-questions')
.querySelectorAll('.opt')
.forEach((btn) => {
btn.addEventListener('click', () => {
const input = btn.closest('.q').querySelector('input')
input.value = btn.dataset.val
input.focus()
})
})
}
async function submitModal(skip) {
const questions = [...$('modal-questions').querySelectorAll('.q')]
const answers = questions.map((q) => ({
question: q.querySelector('label')?.textContent || '',
answer: skip ? '' : q.querySelector('input')?.value?.trim() || '',
}))
await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
$('modal').classList.add('hidden')
state.modalIteration = null
}
// ---------- launcher (plan picker) ----------
async function loadGoals() {
try {
const res = await fetch('/api/goals')
state.goals = await res.json()
} catch {
state.goals = []
}
const sel = $('goal-select')
if (!state.goals.length) {
sel.innerHTML = ''
selectGoal('')
return
}
sel.innerHTML = state.goals
.map((g) => ``)
.join('')
const keep = state.goals.some((g) => g.file === state.selectedFile) ? state.selectedFile : state.goals[0].file
sel.value = keep
selectGoal(keep)
}
function selectGoal(file) {
state.selectedFile = file
const g = state.goals.find((x) => x.file === file)
const meta = $('goal-meta')
const list = $('goal-stages')
for (const id of ['btn-mark-all', 'btn-clear-all', 'btn-delete-goal']) {
const btn = $(id)
if (btn) btn.disabled = !g
}
if (!g) {
meta.textContent = ''
list.innerHTML = ''
return
}
const last = g.lastRun
? `last run ${g.lastRun.status} · ${g.lastRun.iterations} it · $${(g.lastRun.cost || 0).toFixed(4)}`
: 'never run'
meta.textContent = `${g.file} — ${g.done}/${g.total} done (${g.percent}%) · ${g.type} · ${last}`
$('global-verify').value = g.globalVerify || ''
list.innerHTML =
g.stages
.map(
(st, i) => `
${st.done ? 'done' : 'todo'}
${i + 1}. ${esc(st.title)}
`,
)
.join('') || 'This plan has no stages.
'
}
async function saveStageVerify(stageIndex, verify) {
if (!state.selectedFile) return
try {
const res = await fetch('/api/goals/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: state.selectedFile, stageIndex, verify }),
})
const data = await res.json()
if (!data.ok) {
$('launcher-error').textContent = data.error || 'save failed'
return
}
const i = state.goals.findIndex((x) => x.file === state.selectedFile)
if (i >= 0) state.goals[i] = data.goal
selectGoal(state.selectedFile)
} catch (err) {
$('launcher-error').textContent = err.message
}
}
async function saveGlobalVerify() {
try {
const res = await fetch('/api/goals/verify-global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verify: $('global-verify').value }),
})
const data = await res.json()
if (!data.ok) {
$('launcher-error').textContent = data.error || 'save failed'
return
}
await loadGoals()
} catch (err) {
$('launcher-error').textContent = err.message
}
}
// POST a plan action scoped to the currently selected file and surface errors.
async function postGoalAction(path, body, label) {
if (!state.selectedFile) return null
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: state.selectedFile, ...body }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok || data.ok === false) {
$('launcher-error').textContent = data.error || `${label} failed (${res.status})`
return null
}
return data
} catch (err) {
$('launcher-error').textContent = err.message
return null
}
}
async function setGoalDone(done) {
$('launcher-error').textContent = ''
const data = await postGoalAction('/api/goals/done', { done }, done ? 'mark' : 'clear')
if (data) await loadGoals()
}
async function deleteGoal() {
const g = state.goals.find((x) => x.file === state.selectedFile)
const label = g?.title || state.selectedFile
const ok = confirm(
`Delete the plan "${label}"?\n\nThe file ${state.selectedFile} is moved to .kilocode-loop/trash/ so it can be restored. This is not a commit.`,
)
if (!ok) return
$('launcher-error').textContent = ''
const data = await postGoalAction('/api/goals/delete', {}, 'delete')
if (data) {
state.selectedFile = ''
await loadGoals()
}
}
async function startRun() {
if (!state.selectedFile) {
$('launcher-error').textContent = 'Pick a plan first.'
return
}
$('launcher-error').textContent = ''
const body = {
file: state.selectedFile,
iterations: Number($('opt-iterations').value) || 5,
agent: $('opt-agent').value || undefined,
hitl: $('opt-hitl').value,
dryRun: $('opt-dryrun').checked,
promptExtra: $('opt-prompt-extra').value.trim() || undefined,
}
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok || !data.ok) {
$('launcher-error').textContent = data.error || `start failed (${res.status})`
return
}
state.forceLauncher = false
} catch (err) {
$('launcher-error').textContent = err.message
}
}
$('goal-select').addEventListener('change', (e) => selectGoal(e.target.value))
$('goal-stages').addEventListener('change', (e) => {
const input = e.target.closest('input[data-stage]')
if (input) saveStageVerify(Number(input.dataset.stage), input.value)
})
$('btn-save-global-verify').addEventListener('click', saveGlobalVerify)
$('btn-goals-refresh').addEventListener('click', loadGoals)
$('btn-mark-all').addEventListener('click', () => setGoalDone(true))
$('btn-clear-all').addEventListener('click', () => setGoalDone(false))
$('btn-delete-goal').addEventListener('click', deleteGoal)
$('btn-start').addEventListener('click', startRun)
$('btn-new-run').addEventListener('click', () => {
state.forceLauncher = true
loadGoals()
render(state.data || { status: 'idle', idle: true })
})
// ---------- controls ----------
async function control(action, extra) {
try {
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...(extra || {}) }),
})
} catch {
/* ignore */
}
}
// ---------- state ----------
function render(s) {
state.data = s
// Top-bar activity indicator (CSS animation drives it; JS only sets state).
const bar = $('topbar')
if (bar) {
bar.classList.remove(...TOPBAR_CLASSES)
const barState = topbarState(s)
if (barState) bar.classList.add(`topbar--${barState}`)
}
const idle = s.idle === true
const stopped = ['done', 'done-with-errors', 'aborted', 'error', 'stopped-budget', 'stalled', 'stopped-guard'].includes(s.status)
const showLauncher = idle || state.forceLauncher
$('launcher').classList.toggle('hidden', !showLauncher)
$('run-view').classList.toggle('hidden', showLauncher)
$('toggle-shared').classList.toggle('hidden', idle)
$('btn-new-run').classList.toggle('hidden', idle || !stopped)
// When a run finishes, refresh the plan list so the launcher shows new state.
if (state.prevStatus && ACTIVE_STATUSES.has(state.prevStatus) && !ACTIVE_STATUSES.has(s.status)) loadGoals()
state.prevStatus = s.status
if (idle) {
$('run-meta').textContent = 'no active run — pick a plan'
const pillIdle = $('status-pill')
pillIdle.textContent = 'idle'
pillIdle.className = 'status'
return
}
const pauseBtn = $('btn-pause')
const paused = s.status === 'paused' || s.pauseRequested
pauseBtn.textContent = paused ? 'Resume' : 'Pause'
pauseBtn.disabled = stopped
$('btn-abort').textContent = s.abortRequested ? 'Abort armed ✓' : 'Abort after session'
$('btn-abort').classList.toggle('primary', Boolean(s.abortRequested))
const pill = $('status-pill')
pill.textContent = s.status
pill.className = `status ${s.status}`
const goal = s.goal?.title || '—'
const mode = s.sharedContext ? 'shared context' : 'fresh sessions'
const custom = s.customInstruction ? ' · custom instruction' : ''
$('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent} · ${mode}${custom}${s.dryRun ? ' · DRY-RUN' : ''}`
const sharedChk = $('chk-shared')
if (document.activeElement !== sharedChk) sharedChk.checked = Boolean(s.sharedContext)
sharedChk.disabled = stopped
$('toggle-shared').classList.toggle('on', Boolean(s.sharedContext))
const exportLink = $('btn-export')
if (exportLink) exportLink.href = s.runId ? `/api/runs/${encodeURIComponent(s.runId)}/export` : '#'
renderStats(s)
renderStages(s)
renderIterations(s)
renderModal(s)
}
function connect() {
const es = new EventSource('/api/events')
es.onmessage = (ev) => {
let msg
try {
msg = JSON.parse(ev.data)
} catch {
return
}
if (msg.type === 'state') {
if (!state.logSeeded) {
state.logSeeded = true
seedLog(msg.state.logs)
}
render(msg.state)
} else if (msg.type === 'log') {
appendLog(msg.entry)
}
}
es.onerror = () => {
// EventSource reconnects automatically; surface it in the status pill.
const pill = $('status-pill')
if (pill) pill.textContent = 'reconnecting…'
}
}
$('btn-pause').addEventListener('click', () => {
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
control(paused ? 'resume' : 'pause')
})
$('btn-abort').addEventListener('click', () => control('abort-after-current'))
$('btn-stop').addEventListener('click', () => {
if (confirm('Stop the loop now and kill the current session?')) control('stop')
})
// Revert the working tree to the checkpoint taken before an iteration.
$('iterations').addEventListener('click', async (e) => {
const btn = e.target.closest('.revert')
if (!btn) return
const iteration = Number(btn.dataset.iter)
if (!confirm(`Revert the working tree to the checkpoint taken before iteration ${iteration}?\n\nThis discards tracked and untracked changes made since then. It is not a commit and cannot be undone from here.`)) return
try {
const res = await fetch('/api/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iteration }),
})
const data = await res.json()
if (!data.ok) alert(data.error || 'revert failed')
} catch (err) {
alert(`revert failed: ${err.message}`)
}
})
$('chk-shared').addEventListener('change', (e) => control('set-shared-context', { value: e.target.checked }))
$('btn-submit').addEventListener('click', () => submitModal(false))
$('btn-skip').addEventListener('click', () => submitModal(true))
// Enter submits inside the modal.
$('modal').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
submitModal(false)
}
})
// Terminal-style shortcuts mirror the CLI controls.
document.addEventListener('keydown', (e) => {
if (!$('modal').classList.contains('hidden')) return
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
const k = e.key.toLowerCase()
if (k === 'x') control('abort-after-current')
else if (k === 'p') {
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
control(paused ? 'resume' : 'pause')
} else if (k === 'q' && e.shiftKey) control('stop')
})
loadGoals()
connect()