'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 ? `` : '' 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()