248 lines
9 KiB
JavaScript
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()
|