kilo-loop/bin/kilocode-loop.mjs
2026-09-13 22:38:01 +03:00

248 lines
9 KiB
JavaScript

#!/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()