kilo-loop/bin/kilocode-loop.mjs

219 lines
7.4 KiB
JavaScript
Raw Normal View History

2026-09-13 10:46:28 +00:00
#!/usr/bin/env node
import fs from 'node:fs'
import { DEFAULTS, USAGE, loadConfig } from '../src/config.mjs'
import { Orchestrator } from '../src/orchestrator.mjs'
2026-09-13 16:29:03 +00:00
import { RunController } from '../src/controller.mjs'
2026-09-13 10:46:28 +00:00
import { startServer, runsBaseDir } from '../src/server.mjs'
import { installTerminalControls } from '../src/terminal.mjs'
import { printLog } from '../src/console.mjs'
import { loadRun } from '../src/state.mjs'
2026-09-13 16:29:03 +00:00
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) {
process.stdout.write(`\n ${c.gray('Dashboard')} ${url} ${c.gray('(Ctrl+Click to open)')}\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`)
}
}
2026-09-13 10:46:28 +00:00
async function main() {
2026-09-13 16:29:03 +00:00
// `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)))
}
2026-09-13 10:46:28 +00:00
let config
try {
2026-09-13 16:29:03 +00:00
config = loadConfig(argv)
2026-09-13 10:46:28 +00:00
} 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)
}
2026-09-13 16:29:03 +00:00
if (config.listGoals) {
printGoals(config.project)
return
}
2026-09-13 10:46:28 +00:00
if (config.promptExtra && fs.existsSync(config.promptExtra)) {
config.promptExtra = fs.readFileSync(config.promptExtra, 'utf8')
}
2026-09-13 16:29:03 +00:00
// 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.')
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)
}
2026-09-13 10:46:28 +00:00
2026-09-13 16:29:03 +00:00
// --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()
2026-09-13 10:46:28 +00:00
if (config.resume) {
try {
2026-09-13 16:29:03 +00:00
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)
}
}
2026-09-13 10:46:28 +00:00
} catch {
process.stderr.write(` Warning: could not load run "${config.resume}" — starting fresh.\n`)
}
}
2026-09-13 16:29:03 +00:00
process.stdout.write(renderPreflight(config, goal, `http://${config.host}:${config.port}`))
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
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)
2026-09-13 10:46:28 +00:00
const controls = installTerminalControls(orchestrator)
2026-09-13 16:29:03 +00:00
dashboardLink(dashboard.url)
2026-09-13 10:46:28 +00:00
let exitCode = 0
const shutdown = async (code) => {
controls.dispose()
2026-09-13 16:29:03 +00:00
// Hard guard: never hang on exit even if close() misbehaves.
const guard = setTimeout(() => process.exit(code), 3000)
guard.unref?.()
2026-09-13 10:46:28 +00:00
await dashboard.close().catch(() => {})
2026-09-13 16:29:03 +00:00
clearTimeout(guard)
2026-09-13 10:46:28 +00:00
process.exit(code)
}
2026-09-13 16:29:03 +00:00
const runPromise = orchestrator.run()
installSignalHandlers(async () => {
2026-09-13 10:46:28 +00:00
orchestrator.requestStop()
2026-09-13 16:29:03 +00:00
await Promise.race([runPromise.catch(() => {}), delay(8000)])
2026-09-13 10:46:28 +00:00
})
try {
2026-09-13 16:29:03 +00:00
const final = await runPromise
exitCode = EXIT_CODES[final.status] ?? 1
2026-09-13 10:46:28 +00:00
} catch (err) {
process.stderr.write(`\n Fatal: ${err?.stack || err}\n`)
exitCode = 1
}
if (config.keepOpen) {
2026-09-13 16:29:03 +00:00
process.stdout.write(`\n Dashboard still open: ${dashboard.url} (Ctrl+Click to open, Ctrl+C to exit)\n`)
2026-09-13 10:46:28 +00:00
return
}
await shutdown(exitCode)
}
main()