From d6914fb026881c3d2a0290d231b70229041c55b0 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Sat, 10 Oct 2026 21:37:26 +0300 Subject: [PATCH] kilo fix --- bin/kilocode-loop.mjs | 247 ++++++++++++++++++++++++++++++++++++++++++ kilo-loop | 9 +- 2 files changed, 254 insertions(+), 2 deletions(-) create mode 100755 bin/kilocode-loop.mjs diff --git a/bin/kilocode-loop.mjs b/bin/kilocode-loop.mjs new file mode 100755 index 0000000..1916b7f --- /dev/null +++ b/bin/kilocode-loop.mjs @@ -0,0 +1,247 @@ +#!/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() diff --git a/kilo-loop b/kilo-loop index dc7341e..328393c 100755 --- a/kilo-loop +++ b/kilo-loop @@ -22,8 +22,13 @@ if [ "${1:-}" = "new-plan" ]; then exec node "$APP_DIR/new-plan.mjs" "$@" fi -# Project = git root of the current directory (submodules resolve to themselves). -ROOT="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD")" +# Project = the repo that owns the plans. When the loop runs from inside its own +# git submodule, target the SUPERPROJECT (the consuming repo) so its `.kilo/plans/` +# is discovered; otherwise use the current git root (or PWD when outside git). +ROOT="$(git -C "$PWD" rev-parse --show-superproject-working-tree 2>/dev/null || true)" +if [ -z "$ROOT" ]; then + ROOT="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD")" +fi if [ ! -d "$APP_DIR/node_modules" ]; then echo "[kilo-loop] installing dependencies in $APP_DIR ..." >&2