#!/usr/bin/env node import fs from 'node:fs' import { DEFAULTS, USAGE, loadConfig } from '../src/config.mjs' import { Orchestrator } from '../src/orchestrator.mjs' import { RunController } from '../src/controller.mjs' import { startServer, runsBaseDir } from '../src/server.mjs' import { installTerminalControls } from '../src/terminal.mjs' import { printLog } from '../src/console.mjs' import { listRuns, loadRun } from '../src/state.mjs' import { dashboardUrls } from '../src/net.mjs' import { guardFeedbackText } from '../src/guard.mjs' import { loadGoal } from '../src/goal.mjs' import { listGoalSummaries } from '../src/goals.mjs' import { confirmStart, renderPreflight } from '../src/preflight.mjs' import { runReportCommand } from '../src/reportcli.mjs' import { c } from '../src/ansi.mjs' /** Exit codes by final run status (documented in the README). */ export const EXIT_CODES = { done: 0, 'done-with-errors': 1, aborted: 2, 'stopped-budget': 3, stalled: 4, 'stopped-guard': 5, } function dashboardLink(url, note, lan = []) { process.stdout.write(`\n ${c.gray('Dashboard')} ${url} ${c.gray('(Ctrl+Click to open)')}\n`) for (const u of lan) { if (u !== url) process.stdout.write(` ${c.gray('On this network (e.g. phone)')} ${c.bold(u)}\n`) } if (note) process.stdout.write(` ${c.gray(note)}\n`) } /** Resolve after `ms`, without keeping the process alive just for the timer. */ function delay(ms) { return new Promise((resolve) => { const t = setTimeout(resolve, ms) t.unref?.() }) } /** * Handle SIGINT/SIGTERM so Ctrl+C always stops the loop and exits — even when a * dashboard tab holds an SSE connection open. A second signal exits immediately, * and a hard guard force-exits if graceful cleanup misbehaves. */ function installSignalHandlers(onSignal, exitCode = 130) { let signals = 0 const handler = async () => { signals += 1 if (signals > 1) process.exit(exitCode) const guard = setTimeout(() => process.exit(exitCode), 8000) guard.unref?.() try { await onSignal() } catch { /* ignore — we are exiting anyway */ } clearTimeout(guard) process.exit(exitCode) } process.on('SIGINT', handler) process.on('SIGTERM', handler) } function printGoals(project) { const goals = listGoalSummaries(project) if (!goals.length) { process.stdout.write( `\n No goals found. Add JSON/Markdown goals under .kilocode-loop/goals/, or a markdown plan with "- [ ]" items under plans/ or .kilo/plans/.\n\n`, ) return } process.stdout.write(`\n ${c.bold('Goals / plans')} ${c.gray(project)}\n\n`) for (const g of goals) { const last = g.lastRun ? `${g.lastRun.status} · ${g.lastRun.startedAt}` : 'never run' const verify = g.globalVerify ? ` · global verify: ${g.globalVerify}` : '' process.stdout.write(` ${String(g.percent).padStart(3)}% ${String(g.done)}/${g.total} ${c.bold(g.file)}\n`) process.stdout.write(` ${g.title}${c.gray(verify)}\n`) process.stdout.write(` ${c.gray(`last run: ${last}`)}\n\n`) } } async function main() { // `kilo-loop report` is a read-only subcommand: it must not load a goal or // start anything, so handle it before normal config parsing. const argv = process.argv.slice(2) if (argv[0] === 'report') { process.exit(runReportCommand(argv.slice(1))) } let config try { config = loadConfig(argv) } catch (err) { process.stderr.write(`\n Error: ${err.message}\n\n${USAGE}\n`) process.exit(2) } if (config.help) { process.stdout.write(USAGE + '\n') process.exit(0) } if (config.listGoals) { printGoals(config.project) return } if (config.promptExtra && fs.existsSync(config.promptExtra)) { config.promptExtra = fs.readFileSync(config.promptExtra, 'utf8') } // Launcher mode: no goal on the CLI. Serve the dashboard so a plan can be // picked (and its verify configured) in the UI. if (config.launcher) { const controller = new RunController(config) const dashboard = await startServer(config, controller) dashboardLink(dashboard.url, 'No goal given — pick a plan in the dashboard.', dashboard.urls?.lan || []) installSignalHandlers(async () => { if (controller.isActive()) { controller.requestStop() // Give the current session a moment to die so no child is orphaned. await Promise.race([controller.whenRunSettles(), delay(5000)]) } await dashboard.close().catch(() => {}) }) return } // Resolve the goal up front so the pre-flight shows exactly what will run and // a broken goal file fails before anything is started. let goal try { goal = loadGoal(config) } catch (err) { process.stderr.write(`\n Error: ${err.message}\n`) process.exit(2) } // --resume: load the previous run before the pre-flight so stages it already // completed are shown (and treated) as done instead of being repeated. The // agent may report a variant id (e.g. "phase3-resource-and-policy-cleanup"), // so match by equality or prefix against the goal's stage ids. let prevRun = null const resumedStageIds = new Set() if (config.resume === 'last' || config.resume === 'latest') { const [latest] = listRuns(runsBaseDir(config)) if (latest?.runId) { config.resume = latest.runId } else { process.stderr.write(' Warning: --resume last requested but no saved runs were found — starting fresh.\n') config.resume = '' } } if (config.resume) { try { prevRun = loadRun(runsBaseDir(config), config.resume) const reported = new Set( (prevRun.iterations || []).filter((it) => it.stageStatus === 'done' && it.stageId).map((it) => it.stageId), ) for (const s of goal.stages || []) { if ([...reported].some((d) => d === s.id || d.startsWith(s.id))) { s.done = true resumedStageIds.add(s.id) } } } catch { process.stderr.write(` Warning: could not load run "${config.resume}" — starting fresh.\n`) } } process.stdout.write(renderPreflight(config, goal, dashboardUrls(config).primary)) if (!(await confirmStart(config))) { process.stdout.write(`\n ${c.yellow('Cancelled')} — nothing was started. Edit the goal or config, then run again.\n`) return } const orchestrator = new Orchestrator(config) orchestrator.state.on('log', (entry) => printLog(entry, { quiet: config.quiet })) if (prevRun) { // Only reuse the previous session when shared context is on; fresh runs // start a new session every iteration regardless of --resume. if (prevRun.sessionID && config.sharedContext) orchestrator.state.update({ sessionID: prevRun.sessionID }) for (const id of resumedStageIds) orchestrator.completedStages.add(id) const last = (prevRun.iterations || []).slice(-1)[0] if (last?.percent != null) orchestrator.lastPercent = last.percent // If the previous run was interrupted by the safety guard, carry that note // into this run's first iteration so the agent does not retry the command. const blocked = [...(prevRun.iterations || [])].reverse().find((it) => it.guardViolation) if (blocked) { orchestrator.guardFeedback = guardFeedbackText(blocked.guardViolation) orchestrator.log('warn', `Previous run hit the safety guard (${blocked.guardViolation.id}) — the first iteration carries the safety feedback.`) } orchestrator.log('system', `Resumed run ${config.resume} (session ${prevRun.sessionID || '—'}, last ${orchestrator.lastPercent}%).`) } const controller = new RunController(config) controller.attach(orchestrator) const dashboard = await startServer(config, controller) const controls = installTerminalControls(orchestrator) dashboardLink(dashboard.url, null, dashboard.urls?.lan || []) let exitCode = 0 const shutdown = async (code) => { controls.dispose() // Hard guard: never hang on exit even if close() misbehaves. const guard = setTimeout(() => process.exit(code), 3000) guard.unref?.() await dashboard.close().catch(() => {}) clearTimeout(guard) process.exit(code) } const runPromise = orchestrator.run() installSignalHandlers(async () => { orchestrator.requestStop() await Promise.race([runPromise.catch(() => {}), delay(8000)]) }) try { const final = await runPromise exitCode = EXIT_CODES[final.status] ?? 1 // Continuation aid: a run that did not reach `done` can be resumed with the // same goal, carrying its stage progress and (if any) safety feedback. if (final.status !== 'done' && final.runId) { const resume = config.goal ? `kilo-loop --goal ${config.goal} --resume ${final.runId}` : `kilo-loop --resume ${final.runId} (pass the same --goal / --goal-text)` process.stdout.write(`\n ${c.yellow('Resume this run')}: ${resume}\n`) } } catch (err) { process.stderr.write(`\n Fatal: ${err?.stack || err}\n`) exitCode = 1 } if (config.keepOpen) { process.stdout.write(`\n Dashboard still open: ${dashboard.url} (Ctrl+Click to open, Ctrl+C to exit)\n`) return } await shutdown(exitCode) } main()