diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deeede5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +runs/ +.kilocode-loop/ +*.log +.DS_Store +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf868b2 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# kilocode-loop + +Goal-driven autonomous loop runner for the **Kilo CLI** (`kilo run`), with a live +Express dashboard, per-iteration reports (tokens / cost / context / topic / % / +next stage), streamed section headings for every action, terminal controls and +human-in-the-loop between sessions. + +It is a self-contained mini-app: one Node process spawns `kilo run --format json` +for every iteration, parses the event stream, and turns the agent's own +`progress.json` into the operator report. + +``` +┌─ loop ──────────────────────────────────────────────────────────────────┐ +│ for i in 1..N: │ +│ ┌─ section: Iteration i/N — ───────────────────────────────┐ │ +│ │ ▸ Read src/foo.vue (tool → section heading) │ │ +│ │ ▸ Edit src/foo.vue │ │ +│ │ ▸ Run pnpm typecheck │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ report: tokens · cost · context · topic · % · next stage │ +│ human-in-the-loop: ask the operator if the agent needs a decision │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Requirements + +- Node.js ≥ 20 (developed on Node 24) +- The Kilo CLI on `PATH` (`kilo` / `kilocode`), authenticated +- A git repository to work in (the agent never commits) + +```bash +npm install +``` + +## Quick start + +```bash +# Real run: 5 iterations of the code-design agent against ./your-repo +node bin/kilocode-loop.mjs \ + --goal /path/to/your-repo/.kilocode-loop/goal.json \ + -n 5 \ + --project /path/to/your-repo \ + --agent code-design \ + --port 7999 + +# Offline rehearsal: no API calls, exercises the loop, reports, dashboard and HITL +node bin/kilocode-loop.mjs --dry-run \ + --goal examples/goal.example.json -n 5 --project /tmp/demo --port 7999 +``` + +Then open the dashboard at . + +## Controls + +| Where | Key / button | Effect | +| --- | --- | --- | +| Terminal | `X` | Abort the loop **after** the current session finishes | +| Terminal | `P` | Pause / resume before the next iteration | +| Terminal | `Q` | Stop **now** (kills the current `kilo` session) | +| Terminal | `?` | Print shortcut help | +| Terminal | typing + `Enter` | Answer a pending question (number keys pick an option) | +| Dashboard | Pause / Abort after session / Stop now | Same controls from the browser | +| Dashboard | question modal | Answer the pending questions; answers go into the next session | + +## How a report is produced + +Each iteration the runner writes a prompt that embeds a **contract**: at the end +of the session the agent must write `/.kilocode-loop/progress.json`: + +```json +{ + "iteration": 2, + "topic": "Rework SettingsByokSection", + "stageId": "stage-2", + "stageStatus": "in_progress", + "percent": 40, + "nextStage": "Verify layout and dark theme", + "summary": "Replaced the card grid with a q-select + config panel. Typecheck passes.", + "filesChanged": ["src/components/settings/SettingsByokSection.vue"], + "needsInput": false, + "questions": [] +} +``` + +From that + the CLI event stream the runner derives: + +- **iteration number** — loop counter +- **tokens / cost** — summed from every `step_finish` event +- **context size** — `tokens.total` of the last step (how full the window is) +- **topic** — `progress.topic` +- **% complete** — `progress.percent`, or the ratio of completed goal stages +- **next stage** — `progress.nextStage`, or the first not-done stage +- **files changed** — git delta since the iteration started, plus `filesChanged` + +If the agent forgets the contract, the runner falls back to the goal stages and +scrapes question-looking lines out of the agent's text. + +### Reports on disk + +``` +/.kilocode-loop/ + progress.json # the agent's machine-readable report + answers.json # operator answers from HITL + runs// + state.json # full run state (for the dashboard) + events.jsonl # every log line + report.md # end-of-run summary + iterations/ + 01-prompt.md # exact prompt sent + 01-report.json + 01-report.md +``` + +## Goal files + +`--goal-text "..."` — a single implicit stage. + +`--goal file.json`: + +```json +{ + "title": "Stabilise the workflows module", + "description": "…", + "stages": [{ "id": "stage-1", "title": "Remove legacy libs", "details": "…" }] +} +``` + +`--goal file.md` — the first `#` heading is the title, each `- [ ]` / `- [x]` +item is a stage: + +```markdown +# Stabilise the workflows module + +- [ ] Remove legacy libs +- [x] Rewire the builder +``` + +## CLI reference + +``` +Goal + --goal .json goal or .md checklist + --goal-text "" inline goal + -n, --iterations loop count (default 5) + +Execution + -C, --project repository the agent works in (default cwd) + -a, --agent Kilo agent (default code-design) + -m, --model model override (provider/model) + --variant reasoning variant (e.g. high, max) + --thinking capture reasoning parts + --auto / --no-auto auto-approve permissions (default: auto) + --session-mode continue (one growing session) | fresh + --hitl always | on-question | off + --max-iteration-minutes + --context-warn-tokens + --prompt-extra extra instructions for every iteration + +Dashboard + -p, --port default 7999 + --host default 127.0.0.1 + --keep-open keep the dashboard after the loop + +Utility + --dry-run simulate the agent (no API calls) + --resume reuse a previous run's session + stage progress + --run-id + --quiet suppress the live console stream + --no-color +``` + +Config can also live in `/.kilocode-loop/config.json` (CLI wins). + +## Dashboard API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/state` | Current run snapshot | +| `GET` | `/api/events` | SSE stream (`state` + `log` messages) | +| `GET` | `/api/runs` | List saved runs | +| `GET` | `/api/runs/:id` | Load a saved run (with its log) | +| `GET` | `/api/runs/:id/report/:iter` | Iteration report (markdown) | +| `POST` | `/api/control` | `{ "action": "pause"\|"resume"\|"abort-after-current"\|"stop" }` | +| `POST` | `/api/answer` | `{ "answers": [{ "question": "…", "answer": "…" }] }` | + +## How it drives the Kilo CLI + +`kilo run --format json` emits one JSON object per line: + +```jsonc +{"type":"step_start","timestamp":…,"sessionID":"…","part":{…}} +{"type":"tool_use","part":{"tool":"edit","state":{"status":"completed","input":{…}}}} +{"type":"text","part":{"type":"text","text":"…"}} +{"type":"step_finish","part":{"cost":0.0021,"tokens":{"total":14411,"input":14025,"output":2,"reasoning":0,"cache":{"read":384,"write":0}}}} +{"type":"error","error":…} +``` + +- The runner captures `sessionID` from the first event and passes + `--session ` on later iterations, so `--session-mode continue` keeps one + growing context (and `--session-mode fresh` starts a new one each time). +- Unattended runs need `--auto`: without it, non-interactive permission requests + are auto-rejected by the CLI ("pass --auto for autonomous use"). `--auto` is on + by default; deny destructive permissions in the project `kilo.json` if needed. +- `--thinking` is required for `reasoning` events to be emitted. + +## Safety + +- The runner never commits or pushes; the agent is instructed not to either. +- `--auto` approves permissions the project does not explicitly deny — review + `kilo.json` / `.kilo` permissions before unattended runs. +- `--dry-run` performs no API calls and no file changes (beyond its own + `progress.json`). + +## Adding it to the main project as a submodule + +This repo is standalone and has its own history. To pull it into another repo: + +```bash +# 1. create an empty repo on Forgejo (e.g. w4c-agent/kilocode-loop), then: +cd /path/to/main-repo +git submodule add ssh://git@localhost:13022/w4c-agent/kilocode-loop.git tools/kilocode-loop +git commit -m "chore: add kilocode-loop submodule" +``` + +Because it is a submodule, `node_modules/` stays local and is never committed. + +## Layout + +``` +bin/kilocode-loop.mjs entry point (wires config → orchestrator → dashboard) +src/config.mjs CLI/config parsing +src/orchestrator.mjs the loop, controls, HITL, report assembly +src/kilocode.mjs `kilo run` driver + JSONL event parser +src/goal.mjs goal files, agent contract, progress/percent/questions +src/state.mjs run state, persistence, log stream +src/reports.mjs console/markdown reports, formatting +src/console.mjs live console renderer +src/terminal.mjs TTY controls +src/server.mjs Express + SSE dashboard API +src/simulator.mjs --dry-run agent +public/ dashboard UI +examples/ sample goals +test/ node:test unit tests (npm test) +``` diff --git a/bin/kilocode-loop.mjs b/bin/kilocode-loop.mjs new file mode 100644 index 0000000..1981e60 --- /dev/null +++ b/bin/kilocode-loop.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import path from 'node:path' + +import { DEFAULTS, USAGE, loadConfig } from '../src/config.mjs' +import { Orchestrator } from '../src/orchestrator.mjs' +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' + +async function main() { + let config + try { + config = loadConfig(process.argv.slice(2)) + } 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.promptExtra && fs.existsSync(config.promptExtra)) { + config.promptExtra = fs.readFileSync(config.promptExtra, 'utf8') + } + + const orchestrator = new Orchestrator(config) + orchestrator.state.on('log', (entry) => printLog(entry, { quiet: config.quiet })) + + // --resume: reuse the previous run's session and stage progress. + if (config.resume) { + try { + const prev = loadRun(runsBaseDir(config), config.resume) + if (prev.sessionID) orchestrator.state.update({ sessionID: prev.sessionID }) + const done = (prev.iterations || []).filter((it) => it.stageStatus === 'done' && it.stageId) + for (const it of done) orchestrator.completedStages.add(it.stageId) + const last = (prev.iterations || []).slice(-1)[0] + if (last?.percent != null) orchestrator.lastPercent = last.percent + orchestrator.log('system', `Resumed run ${config.resume} (session ${prev.sessionID || '—'}, last ${orchestrator.lastPercent}%).`) + } catch { + process.stderr.write(` Warning: could not load run "${config.resume}" — starting fresh.\n`) + } + } + + const dashboard = await startServer(config, orchestrator) + const controls = installTerminalControls(orchestrator) + + let exitCode = 0 + const shutdown = async (code) => { + controls.dispose() + await dashboard.close().catch(() => {}) + process.exit(code) + } + + process.on('SIGINT', () => { + orchestrator.requestStop() + }) + + try { + const final = await orchestrator.run() + if (final.status === 'done-with-errors') exitCode = 1 + } catch (err) { + process.stderr.write(`\n Fatal: ${err?.stack || err}\n`) + exitCode = 1 + } + + if (config.keepOpen) { + process.stdout.write(`\n Dashboard still open at ${dashboard.url} (Ctrl+C to exit)\n`) + return + } + await shutdown(exitCode) +} + +main() diff --git a/examples/goal.example.json b/examples/goal.example.json new file mode 100644 index 0000000..3570b76 --- /dev/null +++ b/examples/goal.example.json @@ -0,0 +1,21 @@ +{ + "title": "Make the settings drawer section list-based", + "description": "Refactor the BYOK settings section into a list-based UI (q-select + a single config panel), extract the drawer menu into its own section component, and verify spacing/alignment in the browser.", + "stages": [ + { + "id": "stage-1", + "title": "Extract SettingsDrawerMenuSection", + "details": "Move the drawer navigation markup out of SettingsPage.vue into components/settings/SettingsDrawerMenuSection.vue without changing behaviour." + }, + { + "id": "stage-2", + "title": "Rework SettingsByokSection to a list + panel", + "details": "Replace the provider card grid with a q-select (emit-value/map-options, option-value=id) and a single config panel for the selected provider." + }, + { + "id": "stage-3", + "title": "Verify layout and dark theme", + "details": "Open the app, check field insets are symmetric, and confirm the greyed-text class is used instead of text-grey-*." + } + ] +} diff --git a/examples/goal.example.md b/examples/goal.example.md new file mode 100644 index 0000000..e462636 --- /dev/null +++ b/examples/goal.example.md @@ -0,0 +1,10 @@ +# Stabilise the workflows module + +Review the workflows module, remove dead code and make the new graph/expression +libs self-consistent. Run the frontend build and the workflow tests after each +change. + +- [ ] Remove the legacy workflowGraph/expression/mermaid modules +- [ ] Rewire the builder to src/lib/workflowGraph and workflowExpression +- [ ] Split WorkflowEditorDialog into focused components +- [ ] Verify the workflows pages in the browser diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2a91487 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,891 @@ +{ + "name": "kilocode-loop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kilocode-loop", + "version": "0.1.0", + "dependencies": { + "express": "^5.1.0" + }, + "bin": { + "kilocode-loop": "bin/kilocode-loop.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a4dbd1 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "kilocode-loop", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Goal-driven autonomous loop runner for the Kilo CLI, with a live Express dashboard, per-iteration reports and human-in-the-loop control.", + "bin": { + "kilocode-loop": "./bin/kilocode-loop.mjs" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node bin/kilocode-loop.mjs", + "dry": "node bin/kilocode-loop.mjs --dry-run --goal examples/goal.example.json --iterations 5 --port 7999", + "test": "node --test test/*.test.mjs" + }, + "dependencies": { + "express": "^5.1.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f8824b8 --- /dev/null +++ b/public/app.js @@ -0,0 +1,288 @@ +'use strict' + +const $ = (id) => document.getElementById(id) + +const state = { + data: null, + logSeeded: false, + lastSeq: 0, + modalIteration: null, +} + +const fmt = (n) => Number(n || 0).toLocaleString('en-US') +const fmtCost = (n) => { + const v = Number(n || 0) + if (!v) return '$0.0000' + return v < 0.01 ? `$${v.toFixed(5)}` : `$${v.toFixed(4)}` +} +const fmtDur = (ms) => { + const s = Math.max(0, Math.round((ms || 0) / 1000)) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m` + return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` +} +const esc = (s) => + String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) + +// ---------- stats ---------- +function renderStats(s) { + const totals = s.totals || { tokens: {}, cost: 0, durationMs: 0 } + const done = s.iterations.length + const last = s.iterations[s.iterations.length - 1] + const percent = last?.percent ?? 0 + const context = last?.contextSize ?? 0 + const statuses = s.iterations.map((i) => i.status) + const cards = [ + { + k: 'Progress', + v: `${percent}%`, + bar: percent, + }, + { k: 'Iteration', v: `${done} / ${s.configuredIterations ?? s.iterations.length}` }, + { k: 'Status', v: `${esc(s.status)}` }, + { k: 'Tokens', v: `${fmt(totals.tokens?.total)} in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}` }, + { k: 'Cost', v: fmtCost(totals.cost) }, + { + k: 'Context', + v: `${fmt(context)} tokens`, + }, + ] + $('stats').innerHTML = cards + .map( + (c) => `
${c.k}
${c.v}
${ + c.bar !== undefined ? `
` : '' + }
`, + ) + .join('') + void statuses +} + +// ---------- iterations ---------- +function renderIterations(s) { + const box = $('iterations') + if (!s.iterations.length) { + box.innerHTML = '
No iterations yet.
' + return + } + box.innerHTML = s.iterations + .map((it) => { + const pct = it.percent ?? 0 + const status = it.status || 'pending' + const questions = (it.questions || []).length + ? `
    ${it.questions.map((q) => `
  • ${esc(q.text)}
  • `).join('')}
` + : '' + const files = (it.filesChanged || []).length + ? `
${it.filesChanged.slice(0, 8).map((f) => `${esc(f)}`).join('')}${ + it.filesChanged.length > 8 ? `+${it.filesChanged.length - 8}` : '' + }
` + : '' + return `
+
+ #${it.index} + ${esc(it.topic || 'running…')} + ${esc(status)} +
+
+ ${fmt(it.tokens?.total)} tok + ${fmtCost(it.cost)} + ctx ${fmt(it.contextSize)} + ${fmtDur(it.durationMs)} + ${it.toolCount ? `${it.toolCount} tools` : ''} +
+
Stage: ${esc(it.stageTitle || '—')} [${esc(it.stageStatus || 'n/a')}]
+
Next: ${esc(it.nextStage || '—')}
+ ${it.summary ? `
${esc(it.summary)}
` : ''} + ${files} + ${questions} +
+
` + }) + .join('') + void box +} + +// ---------- log ---------- +function logNode(entry) { + const div = document.createElement('div') + div.className = `line ${entry.level || 'info'}` + const time = new Date(entry.t).toLocaleTimeString('en-US', { hour12: false }) + const prefix = entry.level === 'section' ? '' : `${time}` + div.innerHTML = prefix + esc(entry.text) + return div +} + +function appendLog(entry) { + if (!entry || entry.seq <= state.lastSeq) return + state.lastSeq = entry.seq + const log = $('log') + const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60 + log.appendChild(logNode(entry)) + while (log.childElementCount > 2000) log.removeChild(log.firstChild) + if (nearBottom || entry.level === 'section') log.scrollTop = log.scrollHeight +} + +function seedLog(entries) { + const log = $('log') + log.innerHTML = '' + state.lastSeq = 0 + for (const e of entries || []) { + state.lastSeq = Math.max(state.lastSeq, e.seq || 0) + log.appendChild(logNode(e)) + } + log.scrollTop = log.scrollHeight +} + +// ---------- question modal ---------- +function renderModal(s) { + const modal = $('modal') + const pq = s.pendingQuestion + if (!pq) { + modal.classList.add('hidden') + state.modalIteration = null + return + } + if (state.modalIteration === pq.iteration) return + state.modalIteration = pq.iteration + + const questions = pq.questions.length ? pq.questions : [{ id: 'q1', text: 'Any corrections before the next iteration?', options: [] }] + $('modal-sub').textContent = `Iteration ${pq.iteration} finished. Answers are injected into the next session.` + $('modal-questions').innerHTML = questions + .map( + (q, i) => `
+ + ${ + q.options?.length + ? `
${q.options + .map((o) => ``) + .join('')}
` + : '' + } + +
`, + ) + .join('') + modal.classList.remove('hidden') + + const first = $('modal-questions').querySelector('input') + if (first) setTimeout(() => first.focus(), 30) + + $('modal-questions') + .querySelectorAll('.opt') + .forEach((btn) => { + btn.addEventListener('click', () => { + const input = btn.closest('.q').querySelector('input') + input.value = btn.dataset.val + input.focus() + }) + }) +} + +async function submitModal(skip) { + const questions = [...$('modal-questions').querySelectorAll('.q')] + const answers = questions.map((q) => ({ + question: q.querySelector('label')?.textContent || '', + answer: skip ? '' : q.querySelector('input')?.value?.trim() || '', + })) + await fetch('/api/answer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers }), + }) + $('modal').classList.add('hidden') + state.modalIteration = null +} + +// ---------- controls ---------- +async function control(action) { + try { + await fetch('/api/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }) + } catch { + /* ignore */ + } +} + +// ---------- state ---------- +function render(s) { + state.data = s + const pauseBtn = $('btn-pause') + const paused = s.status === 'paused' || s.pauseRequested + pauseBtn.textContent = paused ? 'Resume' : 'Pause' + pauseBtn.disabled = ['done', 'done-with-errors', 'aborted', 'error'].includes(s.status) + $('btn-abort').textContent = s.abortRequested ? 'Abort armed ✓' : 'Abort after session' + $('btn-abort').classList.toggle('primary', Boolean(s.abortRequested)) + + const pill = $('status-pill') + pill.textContent = s.status + pill.className = `status ${s.status}` + + const goal = s.goal?.title || '—' + $('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent}${s.dryRun ? ' · DRY-RUN' : ''}` + + renderStats(s) + renderIterations(s) + renderModal(s) +} + +function connect() { + const es = new EventSource('/api/events') + es.onmessage = (ev) => { + let msg + try { + msg = JSON.parse(ev.data) + } catch { + return + } + if (msg.type === 'state') { + if (!state.logSeeded) { + state.logSeeded = true + seedLog(msg.state.logs) + } + render(msg.state) + } else if (msg.type === 'log') { + appendLog(msg.entry) + } + } + es.onerror = () => { + // EventSource reconnects automatically; surface it in the status pill. + const pill = $('status-pill') + if (pill) pill.textContent = 'reconnecting…' + } +} + +$('btn-pause').addEventListener('click', () => { + const paused = state.data?.status === 'paused' || state.data?.pauseRequested + control(paused ? 'resume' : 'pause') +}) +$('btn-abort').addEventListener('click', () => control('abort-after-current')) +$('btn-stop').addEventListener('click', () => { + if (confirm('Stop the loop now and kill the current session?')) control('stop') +}) +$('btn-submit').addEventListener('click', () => submitModal(false)) +$('btn-skip').addEventListener('click', () => submitModal(true)) + +// Enter submits inside the modal. +$('modal').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + submitModal(false) + } +}) + +// Terminal-style shortcuts mirror the CLI controls. +document.addEventListener('keydown', (e) => { + if (!$('modal').classList.contains('hidden')) return + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return + const k = e.key.toLowerCase() + if (k === 'x') control('abort-after-current') + else if (k === 'p') { + const paused = state.data?.status === 'paused' || state.data?.pauseRequested + control(paused ? 'resume' : 'pause') + } else if (k === 'q' && e.shiftKey) control('stop') +}) + +connect() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b6f5573 --- /dev/null +++ b/public/index.html @@ -0,0 +1,57 @@ + + + + + + kilocode-loop + + + +
+
+ +
+
kilocode-loop
+
connecting…
+
+
+
+
starting
+
+ + + +
+
+ +
+ +
+
+

Iterations

+
+
+
+

+ Live session + sections mark the current action · X abort after current · P pause · Q stop +

+
+
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..beefdc7 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,245 @@ +:root { + --bg: #0d1117; + --bg-2: #161b22; + --bg-3: #1c2330; + --border: #2a3240; + --fg: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --green: #3fb950; + --yellow: #d29922; + --red: #f85149; + --purple: #bc8cff; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; +} + +body { display: flex; flex-direction: column; } + +/* ---------- top bar ---------- */ +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 18px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.brand { display: flex; align-items: center; gap: 12px; } +.logo { + display: grid; + place-items: center; + width: 34px; height: 34px; + border-radius: 9px; + background: linear-gradient(135deg, #1f6feb, #8957e5); + font-size: 20px; +} +.title { font-weight: 650; letter-spacing: 0.2px; } +.sub { color: var(--muted); font-size: 12px; font-family: var(--mono); } +.spacer { flex: 1; } + +.status { + padding: 5px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + border: 1px solid var(--border); + background: var(--bg-3); +} +.status.running { color: var(--accent); border-color: #1f6feb55; background: #1f6feb1a; } +.status.paused { color: var(--yellow); border-color: #d2992255; background: #d299221a; } +.status.waiting-answer { color: var(--purple); border-color: #bc8cff55; background: #bc8cff1a; } +.status.done { color: var(--green); border-color: #3fb95055; background: #3fb9501a; } +.status.done-with-errors, .status.error { color: var(--red); border-color: #f8514955; background: #f851491a; } +.status.aborted { color: var(--yellow); border-color: #d2992255; background: #d299221a; } + +.controls { display: flex; gap: 8px; } +button { + font: inherit; + font-size: 13px; + color: var(--fg); + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 7px; + padding: 7px 12px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +button:hover { background: #232c3a; border-color: #3a4456; } +button.danger { color: #ffb4ae; border-color: #f8514955; } +button.danger:hover { background: #f851491a; } +button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; } +button.primary:hover { background: #2f7cf6; } +button:disabled { opacity: 0.45; cursor: not-allowed; } + +/* ---------- stats ---------- */ +.stats { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg-2); +} +.stat { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + min-width: 0; +} +.stat .k { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; } +.stat .v { font-size: 18px; font-weight: 650; margin-top: 3px; font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.stat .v small { font-size: 12px; color: var(--muted); font-weight: 400; } +.bar { height: 6px; border-radius: 4px; background: #263041; margin-top: 8px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: linear-gradient(90deg, #1f6feb, #8957e5); } + +/* ---------- main ---------- */ +main { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(360px, 44%) 1fr; + gap: 14px; + padding: 14px 18px 18px; +} +.panel { + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.panel h2 { + margin: 0; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-2); +} +.hint { color: var(--muted); font-weight: 400; font-size: 11px; } +.hint b { color: var(--accent); } + +/* ---------- iterations ---------- */ +.iterations { overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.iter { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-3); + padding: 11px 12px; +} +.iter.running { border-color: #1f6feb88; box-shadow: 0 0 0 1px #1f6feb33 inset; } +.iter.failed { border-color: #f8514977; } +.iter .head { display: flex; align-items: baseline; gap: 8px; } +.iter .num { font-family: var(--mono); color: var(--muted); font-size: 12px; } +.iter .topic { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); } +.badge.completed { color: var(--green); border-color: #3fb95055; } +.badge.failed { color: var(--red); border-color: #f8514955; } +.badge.interrupted { color: var(--yellow); border-color: #d2992255; } +.badge.running { color: var(--accent); border-color: #1f6feb55; } +.iter .meta { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 7px; color: var(--muted); font-size: 12px; font-family: var(--mono); } +.iter .stage { margin-top: 6px; font-size: 12px; } +.iter .stage .lbl { color: var(--muted); } +.iter .summary { margin-top: 7px; color: #c9d1d9; font-size: 12.5px; line-height: 1.45; } +.iter .files { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 5px; } +.chip { font-family: var(--mono); font-size: 11px; background: #202a38; border: 1px solid var(--border); border-radius: 6px; padding: 2px 7px; color: #a5b3c4; } +.iter .bar { margin-top: 8px; } +.qlist { margin: 7px 0 0; padding-left: 16px; color: var(--purple); font-size: 12px; } +.empty { color: var(--muted); padding: 16px; text-align: center; } + +/* ---------- log ---------- */ +.log { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +.log .line { padding: 1px 0; } +.log .t { color: #4d5866; margin-right: 8px; } +.log .section { + color: var(--purple); + font-weight: 650; + border-top: 1px solid #232c3a; + margin-top: 8px; + padding-top: 6px; +} +.log .tool { color: var(--accent); } +.log .metric { color: var(--muted); } +.log .agent { color: #d5dee7; } +.log .reasoning { color: #6e7681; font-style: italic; } +.log .warn { color: var(--yellow); } +.log .error { color: var(--red); } +.log .answer { color: var(--green); } +.log .system { color: #6cb6ff; } +.log .stderr { color: #b98a00; } + +/* ---------- modal ---------- */ +.modal { + position: fixed; + inset: 0; + background: #000000aa; + display: grid; + place-items: center; + z-index: 50; + backdrop-filter: blur(2px); +} +.modal.hidden { display: none; } +.modal-card { + width: min(680px, 92vw); + max-height: 86vh; + overflow-y: auto; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 20px 22px; +} +.modal-card h3 { margin: 0 0 4px; } +.muted { color: var(--muted); margin: 0 0 14px; font-size: 12.5px; } +.q { margin-bottom: 14px; } +.q label { display: block; margin-bottom: 6px; font-weight: 550; } +.q .opts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; } +.q input[type="text"] { + width: 100%; + font: inherit; + font-family: var(--mono); + font-size: 13px; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 11px; +} +.q input[type="text"]:focus { outline: none; border-color: var(--accent); } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } + +@media (max-width: 980px) { + .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } + main { grid-template-columns: 1fr; } +} diff --git a/src/ansi.mjs b/src/ansi.mjs new file mode 100644 index 0000000..9a97259 --- /dev/null +++ b/src/ansi.mjs @@ -0,0 +1,37 @@ +// Minimal ANSI colour helpers (no dependency). Respects NO_COLOR / --no-color. + +let enabled = process.env.NO_COLOR ? false : process.stdout.isTTY === true + +export function setColorEnabled(value) { + enabled = Boolean(value) +} + +export function colorEnabled() { + return enabled +} + +const wrap = (open, close) => (text) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : String(text)) + +export const c = { + reset: (t) => (enabled ? `\x1b[0m${t}\x1b[0m` : String(t)), + bold: wrap(1, 22), + dim: wrap(2, 22), + italic: wrap(3, 23), + underline: wrap(4, 24), + red: wrap(31, 39), + green: wrap(32, 39), + yellow: wrap(33, 39), + blue: wrap(34, 39), + magenta: wrap(35, 39), + cyan: wrap(36, 39), + gray: wrap(90, 39), +} + +export function stripAnsi(text) { + return String(text).replace(/\x1b\[[0-9;]*m/g, '') +} + +// Visible length, ignoring ANSI escapes. +export function visibleLength(text) { + return stripAnsi(text).length +} diff --git a/src/config.mjs b/src/config.mjs new file mode 100644 index 0000000..e67d150 --- /dev/null +++ b/src/config.mjs @@ -0,0 +1,209 @@ +import fs from 'node:fs' +import path from 'node:path' + +export const DEFAULTS = { + project: process.cwd(), + goal: '', + goalText: '', + iterations: 5, + agent: 'code-design', + model: '', + variant: '', + thinking: false, + auto: true, + sessionMode: 'continue', // continue | fresh + hitl: 'on-question', // always | on-question | off + port: 7999, + host: '127.0.0.1', + runId: '', + resume: '', + dryRun: false, + keepOpen: false, + quiet: false, + color: true, + promptExtra: '', + maxIterationMinutes: 0, + contextWarnTokens: 150000, +} + +const STRING_FLAGS = new Set([ + 'project', + 'goal', + 'goal-text', + 'iterations', + 'agent', + 'model', + 'variant', + 'session-mode', + 'hitl', + 'port', + 'host', + 'run-id', + 'resume', + 'prompt-extra', + 'max-iteration-minutes', + 'context-warn-tokens', +]) + +const BOOL_FLAGS = new Set([ + 'thinking', + 'auto', + 'dry-run', + 'keep-open', + 'quiet', + 'color', + 'help', +]) + +const SHORT = { + n: 'iterations', + C: 'project', + g: 'goal', + p: 'port', + m: 'model', + a: 'agent', + h: 'help', +} + +function camel(name) { + return name.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase()) +} + +function coerce(name, value) { + const key = camel(name) + if (name === 'iterations' || name === 'port' || name === 'max-iteration-minutes' || name === 'context-warn-tokens') { + const n = Number.parseInt(value, 10) + if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid number for --${name}: ${value}`) + return { [key]: n } + } + if (name === 'goal-text') return { goalText: value } + return { [key]: value } +} + +export function parseArgs(argv) { + const out = { _: [] } + for (let i = 0; i < argv.length; i++) { + const token = argv[i] + if (token === '--') { + out._.push(...argv.slice(i + 1)) + break + } + if (token.startsWith('--')) { + let body = token.slice(2) + let inline + const eq = body.indexOf('=') + if (eq !== -1) { + inline = body.slice(eq + 1) + body = body.slice(0, eq) + } + if (body.startsWith('no-') && BOOL_FLAGS.has(body.slice(3))) { + out[camel(body.slice(3))] = false + continue + } + if (BOOL_FLAGS.has(body)) { + out[camel(body)] = inline === undefined ? true : inline !== 'false' + continue + } + if (STRING_FLAGS.has(body)) { + const value = inline !== undefined ? inline : argv[++i] + if (value === undefined) throw new Error(`Missing value for --${body}`) + Object.assign(out, coerce(body, value)) + continue + } + throw new Error(`Unknown option: --${body}`) + } + if (token.startsWith('-') && token.length === 2 && SHORT[token[1]]) { + const name = SHORT[token[1]] + if (name === 'help') { + out.help = true + continue + } + const value = argv[++i] + if (value === undefined) throw new Error(`Missing value for -${token[1]}`) + Object.assign(out, coerce(name, value)) + continue + } + out._.push(token) + } + return out +} + +function readJsonIfExists(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return null + } +} + +/** + * Precedence: CLI args > /.kilocode-loop/config.json > defaults. + */ +export function loadConfig(argv = process.argv.slice(2)) { + const parsed = parseArgs(argv) + if (parsed.help) return { help: true } + + const projectArg = parsed.project ? path.resolve(parsed.project) : DEFAULTS.project + const fileConfig = + readJsonIfExists(path.join(projectArg, '.kilocode-loop', 'config.json')) || + readJsonIfExists(path.join(projectArg, 'kilocode-loop.config.json')) || + {} + + const merged = { ...DEFAULTS, ...fileConfig, ...parsed } + delete merged._ + merged.project = path.resolve(merged.project) + + if (merged.iterations < 1) merged.iterations = 1 + if (!['continue', 'fresh'].includes(merged.sessionMode)) { + throw new Error(`--session-mode must be "continue" or "fresh", got "${merged.sessionMode}"`) + } + if (!['always', 'on-question', 'off'].includes(merged.hitl)) { + throw new Error(`--hitl must be "always", "on-question" or "off", got "${merged.hitl}"`) + } + if (!merged.goal && !merged.goalText) { + throw new Error('A goal is required: pass --goal or --goal-text ""') + } + if (!fs.existsSync(merged.project)) { + throw new Error(`Project directory does not exist: ${merged.project}`) + } + return merged +} + +export const USAGE = ` +kilocode-loop — goal-driven autonomous loop over the Kilo CLI (kilo run) + +Usage: + kilocode-loop --goal [options] + kilocode-loop --goal-text "" -n 5 --project /path/to/repo + +Goal: + --goal Goal file (.json with stages, or .md with "- [ ]" checkboxes) + --goal-text "" Inline goal description (single-stage) + --iterations, -n Number of loop iterations (default ${DEFAULTS.iterations}) + --prompt-extra Extra instructions appended to every iteration prompt + +Execution: + --project, -C Repository the agent works in (default cwd) + --agent, -a Kilo agent to use (default ${DEFAULTS.agent}) + --model, -m Model override (provider/model) + --variant Reasoning variant (e.g. high, max) + --thinking Capture reasoning parts (needs a thinking-capable model) + --auto / --no-auto Auto-approve permissions (default auto; required unattended) + --session-mode continue (one growing session) | fresh (new session per iteration) + --hitl always | on-question | off (human-in-the-loop) + --max-iteration-minutes Hard timeout per iteration (0 = none) + --context-warn-tokens Warn when the context passes this size (default ${DEFAULTS.contextWarnTokens}) + +Dashboard: + --port, -p Express dashboard port (default ${DEFAULTS.port}) + --host Bind host (default ${DEFAULTS.host}) + --keep-open Keep the dashboard running after the loop ends + +Utility: + --dry-run Simulate the agent (no API calls) — exercises the whole loop/UI + --resume Resume/continue a previously saved run's session + --run-id Explicit run id (default: timestamp) + --quiet Suppress the live console stream (reports still print) + --no-color Disable ANSI colours + --help, -h Show this help +`.trim() diff --git a/src/console.mjs b/src/console.mjs new file mode 100644 index 0000000..0c9bb3e --- /dev/null +++ b/src/console.mjs @@ -0,0 +1,59 @@ +import { c, visibleLength } from './ansi.mjs' + +const LEVEL_STYLE = { + section: (t) => c.bold(c.magenta(t)), + info: (t) => t, + agent: (t) => t, + reasoning: (t) => c.dim(c.italic(t)), + tool: (t) => c.cyan(t), + metric: (t) => c.gray(t), + warn: (t) => c.yellow(t), + error: (t) => c.red(t), + answer: (t) => c.green(t), + system: (t) => c.blue(t), +} + +export function printSection(title, { quiet = false } = {}) { + if (quiet) return + const line = `── ${title} ` + const fill = '─'.repeat(Math.max(4, 72 - visibleLength(line))) + process.stdout.write(`\n${c.bold(c.magenta(line + fill))}\n`) +} + +export function printLog(entry, { quiet = false } = {}) { + if (quiet && !['section', 'error', 'warn', 'answer'].includes(entry.level)) return + const style = LEVEL_STYLE[entry.level] || ((t) => t) + switch (entry.level) { + case 'section': + printSection(entry.text) + return + case 'tool': + process.stdout.write(` ${c.cyan('▸')} ${style(entry.text)}\n`) + return + case 'metric': + process.stdout.write(` ${style(entry.text)}\n`) + return + case 'agent': { + const indented = String(entry.text) + .split('\n') + .map((l) => ` ${l}`) + .join('\n') + process.stdout.write(`${style(indented)}\n`) + return + } + case 'reasoning': + process.stdout.write(` ${style(entry.text)}\n`) + return + case 'error': + process.stdout.write(` ${c.red('✖')} ${style(entry.text)}\n`) + return + case 'warn': + process.stdout.write(` ${c.yellow('!')} ${style(entry.text)}\n`) + return + case 'answer': + process.stdout.write(` ${c.green('✔')} ${style(entry.text)}\n`) + return + default: + process.stdout.write(`${style(entry.text)}\n`) + } +} diff --git a/src/git.mjs b/src/git.mjs new file mode 100644 index 0000000..95afc95 --- /dev/null +++ b/src/git.mjs @@ -0,0 +1,51 @@ +import { execFileSync } from 'node:child_process' + +function git(project, args) { + return execFileSync('git', args, { cwd: project, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +export function isGitRepo(project) { + try { + return git(project, ['rev-parse', '--is-inside-work-tree']).trim() === 'true' + } catch { + return false + } +} + +/** + * Paths with pending changes (staged, unstaged or untracked), relative to the project root. + */ +export function changedFiles(project) { + if (!isGitRepo(project)) return [] + try { + const out = git(project, ['status', '--porcelain']) + const paths = new Set() + for (const line of out.split('\n')) { + if (!line.trim()) continue + let rest = line.slice(3) + // Renames: "R old -> new" + if (rest.includes(' -> ')) rest = rest.split(' -> ').pop() + rest = rest.replace(/^"|"$/g, '') + if (rest) paths.add(rest) + } + return [...paths].sort() + } catch { + return [] + } +} + +export function diffStat(project) { + try { + return git(project, ['diff', '--stat', 'HEAD']).trim() + } catch { + return '' + } +} + +export function headCommit(project) { + try { + return git(project, ['rev-parse', '--short', 'HEAD']).trim() + } catch { + return null + } +} diff --git a/src/goal.mjs b/src/goal.mjs new file mode 100644 index 0000000..eb7f4f0 --- /dev/null +++ b/src/goal.mjs @@ -0,0 +1,296 @@ +import fs from 'node:fs' +import path from 'node:path' + +export function loopPaths(project) { + const dir = path.join(project, '.kilocode-loop') + return { + dir, + progress: path.join(dir, 'progress.json'), + answers: path.join(dir, 'answers.json'), + contract: path.join(dir, 'AGENT_CONTRACT.md'), + } +} + +function slug(text) { + return String(text) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 48) +} + +/** + * Load a goal from --goal-text, a .json file, or a .md file with "- [ ]" checkboxes. + */ +export function loadGoal(config) { + if (config.goalText) { + return { + title: config.goalText.split('\n')[0].slice(0, 120), + description: config.goalText, + stages: [{ id: 'stage-1', title: 'Complete the goal', details: '', done: false }], + source: 'inline', + } + } + + const candidates = [path.resolve(config.project, config.goal), path.resolve(process.cwd(), config.goal)] + const file = candidates.find((f) => fs.existsSync(f)) + if (!file) throw new Error(`Goal file not found (tried ${candidates.join(', ')})`) + const raw = fs.readFileSync(file, 'utf8') + + if (file.endsWith('.json')) { + const data = JSON.parse(raw) + const stages = (data.stages || data.milestones || []).map((s, i) => ({ + id: s.id || `stage-${i + 1}`, + title: s.title || s.name || `Stage ${i + 1}`, + details: s.details || s.description || '', + done: Boolean(s.done), + })) + return { + title: data.title || path.basename(file), + description: data.description || '', + stages: stages.length ? stages : [{ id: 'stage-1', title: data.title || 'Complete the goal', details: '', done: false }], + source: file, + } + } + + // Markdown: title = first heading, stages = checkbox list items. + const lines = raw.split('\n') + const titleLine = lines.find((l) => /^#\s+/.test(l)) + const stages = [] + for (const line of lines) { + const m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/) + if (m) { + const title = m[2].replace(/[*_`]/g, '').trim() + stages.push({ id: `stage-${stages.length + 1}-${slug(title)}`, title, details: '', done: m[1].toLowerCase() === 'x' }) + } + } + const description = lines + .filter((l) => !/^#\s+/.test(l) && !/^\s*[-*]\s+\[[ xX]\]/.test(l)) + .join('\n') + .trim() + return { + title: titleLine ? titleLine.replace(/^#\s+/, '').trim() : path.basename(file), + description, + stages: stages.length ? stages : [{ id: 'stage-1', title: 'Complete the goal', details: '', done: false }], + source: file, + } +} + +export function resetProgress(config, runId, iteration = 0) { + const p = loopPaths(config.project) + fs.mkdirSync(p.dir, { recursive: true }) + const initial = { + runId, + iteration, + topic: null, + stageId: null, + stageStatus: 'pending', + percent: null, + nextStage: null, + summary: null, + filesChanged: [], + needsInput: false, + questions: [], + updatedAt: new Date().toISOString(), + } + fs.writeFileSync(p.progress, JSON.stringify(initial, null, 2)) + return initial +} + +export function readProgress(config) { + const p = loopPaths(config.project) + try { + const data = JSON.parse(fs.readFileSync(p.progress, 'utf8')) + if (!data || typeof data !== 'object') return null + return data + } catch { + return null + } +} + +/** + * Normalise a raw progress.json into the fields used for the report. + */ +export function classifyProgress(progress, goal, iteration) { + const stages = goal?.stages || [] + const doneStages = stages.filter((s) => s.done) + const stageCount = stages.length || 1 + + let percent = Number.isFinite(progress?.percent) ? progress.percent : null + if (percent === null) { + const stageById = new Map(stages.map((s) => [s.id, s])) + const marked = new Set(doneStages.map((s) => s.id)) + if (progress?.stageId && progress?.stageStatus === 'done') marked.add(progress.stageId) + // Also trust the current stage id even if not found in the goal list. + if (progress?.stageId && !stageById.has(progress.stageId) && progress?.stageStatus === 'done') marked.add(progress.stageId) + percent = Math.round((marked.size / stageCount) * 100) + } + percent = Math.max(0, Math.min(100, Math.round(percent))) + + const currentStage = + stages.find((s) => s.id === progress?.stageId) || + stages.find((s, i) => i === (progress?.iteration || iteration) - 1) || + stages[0] || + null + + const nextIncomplete = stages.find((s) => !s.done && s.id !== progress?.stageId) + const questions = normaliseQuestions(progress?.questions) + + return { + topic: progress?.topic || currentStage?.title || goal?.title || 'Unknown', + stageId: progress?.stageId || currentStage?.id || null, + stageStatus: progress?.stageStatus || 'pending', + stageTitle: currentStage?.title || null, + percent, + nextStage: progress?.nextStage || nextIncomplete?.title || (percent >= 100 ? '— (goal complete)' : '—'), + summary: progress?.summary || null, + filesChanged: Array.isArray(progress?.filesChanged) ? progress.filesChanged.filter(Boolean) : [], + needsInput: Boolean(progress?.needsInput) || questions.length > 0, + questions, + updatedAt: progress?.updatedAt || null, + } +} + +export function normaliseQuestions(questions) { + if (!Array.isArray(questions)) return [] + return questions + .map((q, i) => { + if (typeof q === 'string') return { id: `q${i + 1}`, text: q, options: [] } + return { + id: q?.id || `q${i + 1}`, + text: q?.text || q?.question || '', + options: Array.isArray(q?.options) ? q.options.map(String) : [], + } + }) + .filter((q) => q.text.trim()) + .slice(0, 8) +} + +/** + * Last-resort: if the agent ignored the contract, pull question-looking lines out of its text. + */ +export function extractQuestionsFromText(texts) { + const text = (texts || []).join('\n') + if (!text) return [] + const found = [] + for (const raw of text.split('\n')) { + const line = raw.trim().replace(/^[-*]\s+/, '').replace(/^\d+[.)]\s+/, '') + if (!line) continue + if (line.length > 240) continue + if (/\?\s*$/.test(line) && !/^(http|```)/i.test(line)) found.push(line) + } + const unique = [...new Set(found)].slice(0, 6) + return unique.map((text, i) => ({ id: `q${i + 1}`, text, options: [] })) +} + +export function readAnswers(config) { + const p = loopPaths(config.project) + try { + const data = JSON.parse(fs.readFileSync(p.answers, 'utf8')) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export function appendAnswers(config, answers) { + if (!answers?.length) return + const p = loopPaths(config.project) + fs.mkdirSync(p.dir, { recursive: true }) + const all = readAnswers(config) + all.push(...answers) + fs.writeFileSync(p.answers, JSON.stringify(all, null, 2)) +} + +const CONTRACT = (ctx) => `# Autonomous loop contract — iteration ${ctx.iteration}/${ctx.total} + +You are running in an unattended, goal-driven loop invoked through the Kilo CLI. +One iteration = one coherent chunk of work. Work directly in the repository at \`${ctx.project}\`. +Do **not** commit and do **not** push. + +## Goal +${ctx.goalTitle} +${ctx.goalDescription || ''} + +## Stages +${ctx.stagesText} + +## Current stage +${ctx.currentStage ? `Stage ${ctx.currentStageIndex + 1}/${ctx.stageCount}: ${ctx.currentStage.title}` : 'No explicit stage — advance the goal.'} +${ctx.currentStage?.details || ''} + +## Progress from previous iterations +${ctx.progressSummary || 'This is the first iteration.'} + +## Operator answers / corrections +${ctx.answersText || '(none)'} + +## Rules +- Follow the repository's AGENTS.md and this agent's own rules. +- Make real, verifiable changes. Run the relevant checks when practical. +- Keep the diff focused on the current stage. +- If you need a human decision, do NOT guess: record it as a question below. + +## Required progress report (machine-read) +At the very END of this iteration, write this exact file: + + ${ctx.progressPath} + +with valid JSON and this shape: + +{ + "iteration": ${ctx.iteration}, + "topic": "", + "stageId": "", + "stageStatus": "in_progress" | "done" | "blocked", + "percent": , + "nextStage": "", + "summary": "<2-4 sentences: what changed and how it was verified>", + "filesChanged": ["", "..."], + "needsInput": , + "questions": [{ "id": "q1", "text": "", "options": [""] }] +} + +This file produces the operator report. Keep it accurate; write JSON only.` + +export function buildIterationPrompt({ config, goal, iteration, total, progress, answers, currentStageIndex }) { + const p = loopPaths(config.project) + const stagesText = (goal.stages || []) + .map((s, i) => `${i + 1}. [${s.done ? 'x' : ' '}] ${s.title}${s.id === progress?.stageId && progress?.stageStatus === 'done' ? ' (done this run)' : ''}`) + .join('\n') + + const progressSummary = progress?.summary + ? `Iteration ${progress.iteration}: ${progress.summary}` + + (progress.topic ? `\nTopic: ${progress.topic}` : '') + + (progress.nextStage ? `\nPlanned next: ${progress.nextStage}` : '') + : '' + + const answersText = (answers || []) + .map((a) => `- Q: ${a.question}\n A: ${a.answer}`) + .join('\n') + + const currentStage = goal.stages?.[currentStageIndex] || null + + const body = CONTRACT({ + iteration, + total, + project: config.project, + goalTitle: goal.title, + goalDescription: goal.description, + stagesText, + currentStage, + currentStageIndex, + stageCount: goal.stages?.length || 1, + progressSummary, + answersText, + progressPath: p.progress, + }) + + return config.promptExtra ? `${body}\n\n## Additional instructions\n${config.promptExtra}` : body +} + +export function writeContractFile(config, contractText) { + const p = loopPaths(config.project) + fs.mkdirSync(p.dir, { recursive: true }) + fs.writeFileSync(p.contract, contractText) +} diff --git a/src/kilocode.mjs b/src/kilocode.mjs new file mode 100644 index 0000000..e1b4122 --- /dev/null +++ b/src/kilocode.mjs @@ -0,0 +1,204 @@ +import { spawn } from 'node:child_process' + +const KILO_BIN = process.env.KILO_BIN || process.env.KILOCODE_BIN || 'kilo' + +function emptyUsage() { + return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 } +} + +/** + * Human-readable one-line label for a tool part, used as a section heading. + */ +export function describeTool(part) { + const tool = part?.tool || 'tool' + const input = part?.state?.input || {} + const title = part?.state?.title + const base = (p) => (p ? String(p).split('/').slice(-2).join('/') : '') + switch (tool) { + case 'read': + return `Read ${base(input.filePath || input.path)}` + case 'edit': + case 'write': + return `${tool === 'edit' ? 'Edit' : 'Write'} ${base(input.filePath || input.path)}` + case 'bash': + return `Run ${String(input.command || '').split('\n')[0].slice(0, 120)}` + case 'grep': + return `Search /${String(input.pattern || '').slice(0, 60)}/` + case 'glob': + return `Find ${String(input.pattern || '').slice(0, 60)}` + case 'list': + return `List ${base(input.path || input.dir)}` + case 'task': + return `Subagent: ${String(input.description || '').slice(0, 80)}` + case 'todowrite': + return 'Update todo list' + case 'webfetch': + return `Fetch ${String(input.url || '').slice(0, 80)}` + default: + return title ? String(title).slice(0, 120) : tool + } +} + +/** + * Build the argv for `kilo run`. + */ +export function buildKiloArgs({ agent, model, variant, thinking, auto, sessionID, title, prompt, continueSession }) { + const args = ['run', '--format', 'json'] + if (agent) args.push('--agent', agent) + if (model) args.push('--model', model) + if (variant) args.push('--variant', variant) + if (thinking) args.push('--thinking') + if (auto) args.push('--auto') + if (sessionID) args.push('--session', sessionID) + else if (continueSession) args.push('--continue') + if (title) args.push('--title', title) + args.push(prompt) + return args +} + +/** + * Run one `kilo run` invocation and stream parsed JSON events. + * + * @returns {Promise<{sessionID, exitCode, error, usage, cost, texts, toolCalls, stderr, durationMs, interrupted}>} + */ +export function runKilo({ config, sessionID, title, prompt, onEvent, signal, continueSession = false }) { + const args = buildKiloArgs({ + agent: config.agent, + model: config.model, + variant: config.variant, + thinking: config.thinking, + auto: config.auto, + sessionID, + title, + prompt, + continueSession, + }) + + return new Promise((resolve) => { + const startedAt = Date.now() + const child = spawn(KILO_BIN, args, { + cwd: config.project, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const result = { + sessionID: sessionID || null, + exitCode: null, + error: null, + usage: emptyUsage(), + cost: 0, + texts: [], + reasoning: [], + toolCalls: [], + stderr: '', + durationMs: 0, + interrupted: false, + } + + let stdoutBuf = '' + let killed = false + + const kill = () => { + if (killed) return + killed = true + result.interrupted = true + try { + child.kill('SIGINT') + } catch { + /* ignore */ + } + setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + /* ignore */ + } + }, 4000).unref?.() + } + + if (signal) { + if (signal.aborted) kill() + else signal.addEventListener('abort', kill, { once: true }) + } + + const handleLine = (line) => { + const trimmed = line.trim() + if (!trimmed) return + let event + try { + event = JSON.parse(trimmed) + } catch { + onEvent?.({ type: 'raw', text: trimmed }) + return + } + if (event.sessionID && !result.sessionID) result.sessionID = event.sessionID + + switch (event.type) { + case 'step_finish': { + const t = event.part?.tokens || {} + const cache = t.cache || {} + result.usage.input += t.input || 0 + result.usage.output += t.output || 0 + result.usage.reasoning += t.reasoning || 0 + result.usage.cacheRead += cache.read || 0 + result.usage.cacheWrite += cache.write || 0 + result.usage.total += t.total || (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + result.cost += event.part?.cost || 0 + break + } + case 'text': { + const text = event.part?.text || '' + if (text) result.texts.push(text) + break + } + case 'reasoning': { + const text = event.part?.text || '' + if (text) result.reasoning.push(text) + break + } + case 'tool_use': { + if (event.part) result.toolCalls.push(event.part) + break + } + case 'error': { + const err = event.error + result.error = typeof err === 'string' ? err : err?.data?.message || err?.name || JSON.stringify(err) + break + } + default: + break + } + onEvent?.(event) + } + + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdoutBuf += chunk + let nl + while ((nl = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, nl) + stdoutBuf = stdoutBuf.slice(nl + 1) + handleLine(line) + } + }) + + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + result.stderr += chunk + onEvent?.({ type: 'stderr', text: chunk }) + }) + + child.on('error', (err) => { + result.error = err.message + onEvent?.({ type: 'error', error: { message: err.message } }) + }) + + child.on('close', (code) => { + if (stdoutBuf.trim()) handleLine(stdoutBuf) + result.exitCode = code + result.durationMs = Date.now() - startedAt + resolve(result) + }) + }) +} diff --git a/src/orchestrator.mjs b/src/orchestrator.mjs new file mode 100644 index 0000000..632bb48 --- /dev/null +++ b/src/orchestrator.mjs @@ -0,0 +1,489 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { RunState } from './state.mjs' +import { + appendAnswers, + buildIterationPrompt, + classifyProgress, + extractQuestionsFromText, + loadGoal, + loopPaths, + readProgress, + resetProgress, + writeContractFile, +} from './goal.mjs' +import { describeTool, runKilo } from './kilocode.mjs' +import { runSimulated } from './simulator.mjs' +import { changedFiles } from './git.mjs' +import { + contextState, + formatCost, + formatDuration, + formatNumber, + renderConsoleReport, + renderFinalSummary, + renderMarkdownReport, +} from './reports.mjs' +import { printSection } from './console.mjs' + +function zeroTokens() { + return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 } +} + +function errText(err) { + if (err == null) return '' + if (typeof err === 'string') return err + return err.data?.message || err.message || err.name || JSON.stringify(err) +} + +export class Orchestrator { + constructor(config) { + this.config = config + this.state = new RunState(config) + this.answers = [] + this.completedStages = new Set() + this.lastPercent = 0 + this._pauseResolver = null + this._answerResolver = null + this._currentAbort = null + this._stopped = false + this.onReport = null + } + + log(level, text, opts) { + return this.state.log(level, text, opts) + } + + // ---- controls (called by terminal + dashboard API) ---- + + requestAbortAfterCurrent() { + const next = !this.state.state.abortRequested + this.state.update({ abortRequested: next }) + this.log('warn', next ? 'Abort requested — the loop will stop after the current session.' : 'Abort cancelled.') + return next + } + + requestPause() { + if (this.state.state.status === 'paused') return true + this.state.update({ pauseRequested: true }) + this.log('warn', 'Pause requested — will pause before the next iteration.') + return true + } + + requestResume() { + if (this._pauseResolver) { + this._pauseResolver() + this._pauseResolver = null + } + this.state.update({ pauseRequested: false }) + return true + } + + requestStop() { + this._stopped = true + this.state.update({ abortRequested: true, status: 'aborted' }) + this.log('error', 'Stop requested — killing the current session now.') + try { + this._currentAbort?.abort() + } catch { + /* ignore */ + } + // Unblock a pending question/pause so the loop can exit. + this._pauseResolver?.() + this._pauseResolver = null + return true + } + + submitAnswers(items) { + const pending = this.state.state.pendingQuestion + if (!pending) return false + const questions = pending.questions || [] + const formatted = (items || []).map((item, i) => { + if (typeof item === 'string') return { question: questions[i]?.text || `Question ${i + 1}`, answer: item } + return { question: item.question || questions[i]?.text || `Question ${i + 1}`, answer: item.answer ?? '' } + }) + this.answers.push(...formatted) + appendAnswers(this.config, formatted) + this.state.update({ status: 'running', pendingQuestion: null }) + for (const a of formatted) { + this.log('answer', `Q: ${a.question}\n A: ${a.answer || '(skipped)'}`) + } + if (this._answerResolver) { + this._answerResolver(formatted) + this._answerResolver = null + } + return true + } + + // ---- main loop ---- + + async run() { + const { config, state } = this + const startedAt = Date.now() + const goal = loadGoal(config) + state.update({ + status: 'running', + goal: { title: goal.title, description: goal.description, source: goal.source }, + stages: goal.stages, + }) + writeContractFile(config, `# Goal contract\n\n${goal.title}\n`) + resetProgress(config, state.runId, 0) + this.log('system', `Run ${state.runId} · goal "${goal.title}" · ${config.iterations} iteration(s) · agent ${config.agent}${config.dryRun ? ' · DRY-RUN' : ''}`) + this.log('system', `Dashboard: http://${config.host}:${config.port} · project: ${config.project}`) + + let failed = false + + for (let i = 1; i <= config.iterations; i++) { + if (this._stopped || state.state.abortRequested) break + + await this._waitWhilePaused() + if (this._stopped || state.state.abortRequested) break + + const report = await this._runIteration(goal, i, config.iterations) + if (!report) break + if (report.status === 'failed') failed = true + + const shouldAsk = + config.hitl === 'always' || (config.hitl === 'on-question' && report.needsInput) + + if (shouldAsk && !this._stopped && !state.state.abortRequested) { + await this._askHuman(report) + } + if (this._stopped || state.state.abortRequested) break + } + + const totals = state.state.totals + totals.durationMs = Date.now() - startedAt + const finalStatus = this._stopped ? 'aborted' : failed ? 'done-with-errors' : 'done' + state.update({ totals: { ...totals } }) + state.end(finalStatus) + + this._writeRunReport(goal, finalStatus) + + printSection('Run finished') + process.stdout.write(renderFinalSummary(state.state, totals) + '\n') + return state.state + } + + _currentStageIndex(goal) { + const idx = (goal.stages || []).findIndex((s) => !s.done && !this.completedStages.has(s.id)) + return idx === -1 ? Math.max(0, (goal.stages || []).length - 1) : idx + } + + async _waitWhilePaused() { + if (!this.state.state.pauseRequested) return + this.state.update({ status: 'paused' }) + this.log('warn', 'Paused. Press R in the terminal or Resume in the dashboard to continue.') + await new Promise((resolve) => { + this._pauseResolver = resolve + }) + this.state.update({ status: 'running', pauseRequested: false }) + } + + async _runIteration(goal, iteration, total) { + const { config, state } = this + const prior = readProgress(config) + const stageIndex = this._currentStageIndex(goal) + const stage = goal.stages?.[stageIndex] || null + const before = new Set(changedFiles(config.project)) + + const prompt = buildIterationPrompt({ + config, + goal, + iteration, + total, + progress: prior, + answers: this.answers, + currentStageIndex: stageIndex, + }) + + const iterDir = path.join(state.dir, 'iterations') + fs.mkdirSync(iterDir, { recursive: true }) + fs.writeFileSync(path.join(iterDir, `${String(iteration).padStart(2, '0')}-prompt.md`), prompt) + + resetProgress(config, state.runId, iteration) + + const rec = { + index: iteration, + startedAt: new Date().toISOString(), + endedAt: null, + status: 'running', + topic: null, + stageTitle: stage?.title || null, + stageId: stage?.id || null, + stageStatus: 'pending', + percent: this.lastPercent, + nextStage: null, + summary: null, + questions: [], + needsInput: false, + tokens: zeroTokens(), + cost: 0, + contextSize: 0, + durationMs: 0, + filesChanged: [], + toolCount: 0, + sessionID: state.state.sessionID, + error: null, + } + state.addIteration(rec) + + printSection(`Iteration ${iteration}/${total} — ${stage?.title || goal.title}`) + this.log('info', `Stage ${stageIndex + 1}/${goal.stages?.length || 1} · ${stage?.title || goal.title}`, { iteration }) + + const ac = new AbortController() + this._currentAbort = ac + let timeout + if (config.maxIterationMinutes > 0) { + timeout = setTimeout(() => { + this.log('error', `Iteration timeout (${config.maxIterationMinutes} min) reached — aborting session.`, { iteration }) + ac.abort() + }, config.maxIterationMinutes * 60_000) + if (timeout.unref) timeout.unref() + } + + const runner = config.dryRun ? runSimulated : runKilo + const result = await runner({ + config, + // The first iteration starts a fresh session; later ones reuse the captured id. + sessionID: state.state.sessionID || undefined, + continueSession: false, + title: `loop ${state.runId} #${iteration} — ${stage?.title || goal.title}`, + prompt, + signal: ac.signal, + onEvent: (ev) => this._handleEvent(ev, iteration, rec), + }) + if (timeout) clearTimeout(timeout) + this._currentAbort = null + + if (result.sessionID && !state.state.sessionID) state.update({ sessionID: result.sessionID }) + rec.sessionID = result.sessionID || state.state.sessionID + rec.durationMs = result.durationMs + rec.endedAt = new Date().toISOString() + + const status = this._stopped + ? 'interrupted' + : result.interrupted + ? 'interrupted' + : result.exitCode !== 0 || result.error + ? 'failed' + : 'completed' + rec.error = result.error || (result.exitCode !== 0 ? `kilo exited with code ${result.exitCode}` : null) + + // --- progress + report derivation --- + const progress = readProgress(config) + let cls = classifyProgress(progress, goal, iteration) + if (!progress || (!cls.questions.length && !cls.summary)) { + const inferred = extractQuestionsFromText(result.texts) + if (!cls.questions.length && inferred.length) cls = { ...cls, questions: inferred, needsInput: true } + } + if (cls.stageStatus === 'done' && cls.stageId) this.completedStages.add(cls.stageId) + + const after = changedFiles(config.project) + const iterationFiles = after.filter((f) => !before.has(f)) + const filesChanged = iterationFiles.length ? iterationFiles : cls.filesChanged + + const report = { + runId: state.runId, + iteration, + total, + status, + topic: cls.topic, + stageTitle: cls.stageTitle || stage?.title || null, + stageId: cls.stageId || stage?.id || null, + stageStatus: cls.stageStatus, + percent: cls.percent, + nextStage: cls.nextStage, + summary: cls.summary, + questions: cls.questions, + needsInput: cls.needsInput, + tokens: rec.tokens, + cost: rec.cost, + contextSize: rec.contextSize, + contextState: contextState(rec.contextSize, config.contextWarnTokens), + contextWarnTokens: config.contextWarnTokens, + durationMs: result.durationMs, + startedAt: rec.startedAt, + endedAt: rec.endedAt, + filesChanged, + toolCount: rec.toolCount, + sessionID: rec.sessionID, + error: rec.error, + exitCode: result.exitCode, + } + + this.lastPercent = cls.percent + + const totals = state.state.totals + const t = totals.tokens + t.input += rec.tokens.input + t.output += rec.tokens.output + t.reasoning += rec.tokens.reasoning + t.cacheRead += rec.tokens.cacheRead + t.cacheWrite += rec.tokens.cacheWrite + t.total += rec.tokens.total + totals.cost += rec.cost + state.update({ totals: { ...totals, tokens: { ...t } } }) + + state.updateIteration(iteration, { + status, + topic: report.topic, + stageTitle: report.stageTitle, + stageId: report.stageId, + stageStatus: report.stageStatus, + percent: report.percent, + nextStage: report.nextStage, + summary: report.summary, + questions: report.questions, + needsInput: report.needsInput, + durationMs: report.durationMs, + endedAt: report.endedAt, + filesChanged, + error: report.error, + sessionID: report.sessionID, + }) + + fs.writeFileSync(path.join(iterDir, `${String(iteration).padStart(2, '0')}-report.json`), JSON.stringify(report, null, 2)) + fs.writeFileSync(path.join(iterDir, `${String(iteration).padStart(2, '0')}-report.md`), renderMarkdownReport(report)) + + process.stdout.write('\n' + renderConsoleReport(report, state.state.totals) + '\n') + this.onReport?.(report) + return report + } + + _handleEvent(ev, iteration, rec) { + switch (ev.type) { + case 'step_start': + this.log('metric', 'step started', { iteration }) + break + case 'tool_use': { + const label = describeTool(ev.part) + rec.toolCount += 1 + this.log('tool', label, { section: label, iteration }) + break + } + case 'text': { + const text = (ev.part?.text || '').trim() + if (text) this.log('agent', text, { iteration }) + break + } + case 'reasoning': { + const text = (ev.part?.text || '').trim() + if (text) this.log('reasoning', text.slice(0, 2000), { iteration }) + break + } + case 'step_finish': { + const t = ev.part?.tokens || {} + const cache = t.cache || {} + rec.tokens.input += t.input || 0 + rec.tokens.output += t.output || 0 + rec.tokens.reasoning += t.reasoning || 0 + rec.tokens.cacheRead += cache.read || 0 + rec.tokens.cacheWrite += cache.write || 0 + rec.tokens.total += t.total || (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + rec.cost += ev.part?.cost || 0 + rec.contextSize = t.total || rec.contextSize + this.log( + 'metric', + `step · +${formatNumber(t.output)} out · context ${formatNumber(rec.contextSize)} · ${formatCost(ev.part?.cost || 0)}`, + { iteration }, + ) + if (this.config.contextWarnTokens && rec.contextSize >= this.config.contextWarnTokens && !rec._contextWarned) { + rec._contextWarned = true + this.log( + 'warn', + `Context is ${formatNumber(rec.contextSize)} tokens (≥ warn threshold ${formatNumber(this.config.contextWarnTokens)}). Consider --session-mode fresh.`, + { iteration }, + ) + } + this.state.updateIteration(iteration, { + tokens: { ...rec.tokens }, + cost: rec.cost, + contextSize: rec.contextSize, + toolCount: rec.toolCount, + }) + break + } + case 'error': { + const text = errText(ev.error) + rec.error = text + this.log('error', text, { iteration }) + break + } + case 'stderr': { + const text = String(ev.text || '').trim() + if (text) this.log('warn', text, { iteration }) + break + } + case 'raw': { + this.log('metric', String(ev.text).slice(0, 400), { iteration }) + break + } + default: + break + } + } + + async _askHuman(report) { + const { config, state } = this + const questions = report.questions?.length + ? report.questions + : config.hitl === 'always' + ? [{ id: 'q1', text: `Iteration ${report.iteration} finished (${report.percent}% · ${report.topic}). Any corrections before the next iteration?`, options: [] }] + : [] + + state.update({ status: 'waiting-answer', pendingQuestion: { iteration: report.iteration, questions } }) + printSection(`Operator input required (after iteration ${report.iteration})`) + if (questions.length) { + questions.forEach((q, i) => { + process.stdout.write(` ${i + 1}. ${q.text}${q.options?.length ? ` ${'\u001b[90m'}(${q.options.join(' / ')})\u001b[0m` : ''}\n`) + }) + } else { + process.stdout.write(' No explicit question — press Enter to continue.\n') + } + process.stdout.write(` ${'\u001b[90m'}Answer in the terminal or at http://${config.host}:${config.port}\u001b[0m\n`) + + await new Promise((resolve) => { + this._answerResolver = resolve + }) + state.update({ status: 'running', pendingQuestion: null }) + } + + _writeRunReport(goal, finalStatus) { + const { state } = this + const lines = [ + `# Run ${state.runId}`, + '', + `- **Goal:** ${goal.title}`, + `- **Status:** ${finalStatus}`, + `- **Project:** ${state.config.project}`, + `- **Agent:** ${state.config.agent}${state.config.model ? ` (${state.config.model})` : ''}`, + `- **Iterations run:** ${state.state.iterations.length}/${state.config.iterations}`, + `- **Total tokens:** ${formatNumber(state.state.totals.tokens.total)}`, + `- **Total cost:** ${formatCost(state.state.totals.cost)}`, + `- **Wall time:** ${formatDuration(state.state.totals.durationMs)}`, + `- **Session:** ${state.state.sessionID || '—'}`, + '', + '## Iterations', + '', + ] + for (const it of state.state.iterations) { + lines.push(`### ${it.index}. ${it.topic || 'untitled'} — ${it.percent ?? 0}% (${it.status})`) + lines.push('') + lines.push(`- Stage: ${it.stageTitle || '—'} (${it.stageStatus})`) + lines.push(`- Next: ${it.nextStage || '—'}`) + lines.push(`- Tokens: ${formatNumber(it.tokens?.total)} · cost ${formatCost(it.cost)} · context ${formatNumber(it.contextSize)}`) + if (it.summary) lines.push(`- Summary: ${it.summary}`) + if (it.filesChanged?.length) lines.push(`- Files: ${it.filesChanged.map((f) => '`' + f + '`').join(', ')}`) + lines.push('') + } + if (this.answers.length) { + lines.push('## Operator answers', '') + for (const a of this.answers) lines.push(`- **Q:** ${a.question}\n **A:** ${a.answer || '(skipped)'}`) + } + fs.writeFileSync(path.join(state.dir, 'report.md'), lines.join('\n')) + } +} + +export { loopPaths } diff --git a/src/reports.mjs b/src/reports.mjs new file mode 100644 index 0000000..00e4239 --- /dev/null +++ b/src/reports.mjs @@ -0,0 +1,152 @@ +import { c, colorEnabled, visibleLength } from './ansi.mjs' + +export function formatNumber(n) { + return Number(n || 0).toLocaleString('en-US') +} + +export function formatCost(n) { + const v = Number(n || 0) + if (v === 0) return '$0.0000' + if (v < 0.01) return `$${v.toFixed(5)}` + return `$${v.toFixed(4)}` +} + +export function formatDuration(ms) { + const s = Math.max(0, Math.round((ms || 0) / 1000)) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m` + return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` +} + +export function formatTokens(n) { + const v = Number(n || 0) + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M` + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` + return String(v) +} + +export function progressBar(percent, width = 24) { + const p = Math.max(0, Math.min(100, Math.round(percent || 0))) + const filled = Math.round((p / 100) * width) + return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}` +} + +export function contextState(contextSize, warn) { + if (!warn) return 'ok' + const ratio = contextSize / warn + if (ratio >= 1) return 'over' + if (ratio >= 0.75) return 'warn' + return 'ok' +} + +function padRight(text, width) { + const len = visibleLength(text) + return len >= width ? text : text + ' '.repeat(width - len) +} + +function box(title, rows, width = 78) { + const inner = width - 2 + const titleText = ` ${title} ` + const topFill = Math.max(0, inner - visibleLength(titleText) - 2) + const top = `${c.gray('╭─')}${c.bold(titleText)}${c.gray('─'.repeat(topFill) + '╮')}` + const body = rows + .map(([label, value]) => { + const l = padRight(c.gray(label), 12) + return `${c.gray('│')} ${l} ${padRight(value, inner - 14)} ${c.gray('│')}` + }) + .join('\n') + const bottom = c.gray(`╰${'─'.repeat(inner)}╯`) + return [top, body, bottom].join('\n') +} + +/** + * @param {object} report iteration report (see reports.mjs buildIterationReport) + * @param {object} totals run totals + */ +export function renderConsoleReport(report, totals) { + const statusColor = + report.status === 'completed' + ? c.green + : report.status === 'failed' + ? c.red + : report.status === 'interrupted' + ? c.yellow + : c.cyan + const statusIcon = + report.status === 'completed' ? '●' : report.status === 'failed' ? '✖' : report.status === 'interrupted' ? '◼' : '◐' + + const stage = report.stageTitle || report.stageId || '—' + const rows = [ + ['Status', `${statusColor(statusIcon + ' ' + report.status)} ${c.gray('Duration')} ${formatDuration(report.durationMs)}`], + ['Topic', c.bold(report.topic || '—')], + ['Stage', `${stage} ${c.gray('[' + (report.stageStatus || 'n/a') + ']')}`], + ['Progress', `${c.cyan(progressBar(report.percent))} ${String(report.percent).padStart(3)}%`], + ['Next', report.nextStage || '—'], + [ + 'Tokens', + `${c.gray('in')} ${formatNumber(report.tokens.input)} ${c.gray('· out')} ${formatNumber(report.tokens.output)} ${c.gray('· reason')} ${formatNumber(report.tokens.reasoning)} ${c.gray('· cache')} ${formatNumber(report.tokens.cacheRead)} ${c.gray('→')} ${c.bold(formatNumber(report.tokens.total))} ${c.gray('processed')}`, + ], + [ + 'Context', + `${formatNumber(report.contextSize)} tokens` + + (report.contextState === 'over' + ? ` ${c.red('▲ over budget')}` + : report.contextState === 'warn' + ? ` ${c.yellow('▲ nearing ' + formatNumber(report.contextWarnTokens))}` + : ''), + ], + ['Cost', `${c.green(formatCost(report.cost))} ${c.gray('· run total')} ${formatCost(totals?.cost || 0)}`], + [ + 'Files', + report.filesChanged.length + ? `${report.filesChanged.length} changed: ${report.filesChanged.slice(0, 3).join(', ')}${report.filesChanged.length > 3 ? `, +${report.filesChanged.length - 3} more` : ''}` + : '—', + ], + ] + if (report.summary) rows.push(['Summary', report.summary.replace(/\s+/g, ' ').slice(0, 220)]) + if (report.questions?.length) rows.push(['Questions', c.yellow(`${report.questions.length} pending — operator input needed`)]) + if (report.error) rows.push(['Error', c.red(String(report.error).slice(0, 220))]) + + return box(`Iteration ${report.iteration}/${report.total}`, rows) +} + +export function renderMarkdownReport(report) { + const lines = [ + `# Iteration ${report.iteration}/${report.total} — ${report.topic || 'untitled'}`, + '', + `- **Status:** ${report.status}`, + `- **Stage:** ${report.stageTitle || report.stageId || '—'} (${report.stageStatus || 'n/a'})`, + `- **Progress:** ${report.percent}%`, + `- **Next stage:** ${report.nextStage || '—'}`, + `- **Duration:** ${formatDuration(report.durationMs)}`, + `- **Tokens:** in ${formatNumber(report.tokens.input)}, out ${formatNumber(report.tokens.output)}, reasoning ${formatNumber(report.tokens.reasoning)}, cache-read ${formatNumber(report.tokens.cacheRead)}, total ${formatNumber(report.tokens.total)}`, + `- **Context size:** ${formatNumber(report.contextSize)} tokens`, + `- **Cost:** ${formatCost(report.cost)}`, + `- **Session:** ${report.sessionID || '—'}`, + ] + if (report.filesChanged.length) { + lines.push('', '## Files changed', ...report.filesChanged.map((f) => `- \`${f}\``)) + } + if (report.summary) lines.push('', '## Summary', '', report.summary) + if (report.questions?.length) { + lines.push('', '## Questions', ...report.questions.map((q) => `- ${q.text}${q.options?.length ? ` _(options: ${q.options.join(' / ')})_` : ''}`)) + } + if (report.error) lines.push('', '## Error', '', '```', String(report.error), '```') + return lines.join('\n') +} + +export function renderFinalSummary(state, totals) { + const rows = [ + ['Iterations', `${state.iterations.length}/${state.configuredIterations ?? state.iterations.length}`], + ['Status', state.status], + ['Total tokens', formatNumber(totals.tokens.total)], + ['Total cost', formatCost(totals.cost)], + ['Wall time', formatDuration(totals.durationMs)], + ['Session', state.sessionID || '—'], + ['Artifacts', state.runId ? `.kilocode-loop/runs/${state.runId}/` : '—'], + ] + return box('Run finished', rows) +} + +export { box, colorEnabled } diff --git a/src/server.mjs b/src/server.mjs new file mode 100644 index 0000000..a603b31 --- /dev/null +++ b/src/server.mjs @@ -0,0 +1,143 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' + +import { listRuns, loadRun, readRunLog } from './state.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const PUBLIC_DIR = path.join(__dirname, '..', 'public') + +export function runsBaseDir(config) { + return path.join(config.project, '.kilocode-loop', 'runs') +} + +/** + * Start the dashboard. `orchestrator` may be null (viewing saved runs only). + * @returns {Promise<{app, server, url, close}>} + */ +export function startServer(config, orchestrator) { + const app = express() + app.use(express.json({ limit: '1mb' })) + app.use(express.static(PUBLIC_DIR)) + + const baseDir = runsBaseDir(config) + + app.get('/api/health', (_req, res) => { + res.json({ ok: true, runId: orchestrator?.state.runId || null, status: orchestrator?.state.state.status || 'idle' }) + }) + + app.get('/api/state', (_req, res) => { + if (!orchestrator) return res.json({ status: 'idle' }) + res.json(orchestrator.state.snapshot()) + }) + + app.get('/api/runs', (_req, res) => { + res.json(listRuns(baseDir).map((r) => ({ + runId: r.runId, + startedAt: r.startedAt, + endedAt: r.endedAt, + status: r.status, + goal: r.goal?.title || null, + agent: r.agent, + iterations: (r.iterations || []).length, + configuredIterations: r.configuredIterations || (r.iterations || []).length, + totalTokens: r.totals?.tokens?.total || 0, + totalCost: r.totals?.cost || 0, + }))) + }) + + app.get('/api/runs/:id', (req, res) => { + try { + const data = loadRun(baseDir, req.params.id) + data.logs = readRunLog(baseDir, req.params.id, 1000) + res.json(data) + } catch { + res.status(404).json({ error: 'run not found' }) + } + }) + + app.get('/api/runs/:id/report/:iter', (req, res) => { + const file = path.join(baseDir, req.params.id, 'iterations', `${String(req.params.iter).padStart(2, '0')}-report.md`) + if (!fs.existsSync(file)) return res.status(404).json({ error: 'report not found' }) + res.type('text/markdown').send(fs.readFileSync(file, 'utf8')) + }) + + app.post('/api/control', (req, res) => { + if (!orchestrator) return res.status(409).json({ error: 'no active run' }) + const action = req.body?.action + let result + switch (action) { + case 'abort-after-current': + result = { abortRequested: orchestrator.requestAbortAfterCurrent() } + break + case 'pause': + result = { paused: orchestrator.requestPause() } + break + case 'resume': + result = { resumed: orchestrator.requestResume() } + break + case 'stop': + result = { stopped: orchestrator.requestStop() } + break + default: + return res.status(400).json({ error: `unknown action: ${action}` }) + } + res.json({ ok: true, ...result }) + }) + + app.post('/api/answer', (req, res) => { + if (!orchestrator) return res.status(409).json({ error: 'no active run' }) + const answers = req.body?.answers + if (answers === undefined) return res.status(400).json({ error: 'answers is required' }) + const ok = orchestrator.submitAnswers(answers) + if (!ok) return res.status(409).json({ error: 'no question is pending' }) + res.json({ ok: true }) + }) + + // ---- SSE: live state + log stream ---- + app.get('/api/events', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + res.write(': connected\n\n') + + const send = (payload) => { + try { + res.write(`data: ${JSON.stringify(payload)}\n\n`) + } catch { + /* client gone */ + } + } + + if (orchestrator) { + send({ type: 'state', state: orchestrator.state.snapshot() }) + const onUpdate = () => send({ type: 'state', state: orchestrator.state.snapshot() }) + const onLog = (entry) => send({ type: 'log', entry }) + orchestrator.state.on('update', onUpdate) + orchestrator.state.on('log', onLog) + const keepAlive = setInterval(() => res.write(': ping\n\n'), 15000) + req.on('close', () => { + clearInterval(keepAlive) + orchestrator.state.off('update', onUpdate) + orchestrator.state.off('log', onLog) + }) + } else { + req.on('close', () => {}) + } + }) + + return new Promise((resolve) => { + const server = app.listen(config.port, config.host, () => { + resolve({ + app, + server, + url: `http://${config.host}:${config.port}`, + close: () => new Promise((r) => server.close(r)), + }) + }) + }) +} diff --git a/src/simulator.mjs b/src/simulator.mjs new file mode 100644 index 0000000..1172912 --- /dev/null +++ b/src/simulator.mjs @@ -0,0 +1,115 @@ +import fs from 'node:fs' +import { loopPaths } from './goal.mjs' + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +/** + * Deterministic offline agent used by --dry-run. Emits the same JSON event shape + * as `kilo run --format json` and writes a progress.json, so the loop, reports, + * dashboard and human-in-the-loop can be exercised without any API calls. + */ +export async function runSimulated({ config, sessionID, prompt, onEvent, signal }) { + const startedAt = Date.now() + const iteration = Number(prompt.match(/iteration (\d+)\//)?.[1] || 1) + const total = Number(prompt.match(/iteration \d+\/(\d+)/)?.[1] || 1) + const sid = sessionID || `ses_sim_${Math.random().toString(36).slice(2, 12)}` + const emit = (type, part) => onEvent?.({ type, timestamp: Date.now(), sessionID: sid, part }) + const text = (t) => emit('text', { type: 'text', text: t, sessionID: sid, time: { start: Date.now(), end: Date.now() } }) + + const aborted = () => signal?.aborted + const guard = async (ms) => { + const step = 150 + for (let waited = 0; waited < ms; waited += step) { + if (aborted()) return false + await sleep(step) + } + return true + } + + emit('step_start', { type: 'step-start', sessionID: sid }) + await guard(400) + text(`[dry-run] Iteration ${iteration}/${total}: analysing the goal and current stage.`) + await guard(300) + + const tasks = ['read AGENTS.md', 'grep TODO', 'edit src/example.ts', 'bash pnpm test'] + for (const task of tasks) { + if (aborted()) break + emit('tool_use', { + type: 'tool', + tool: task.split(' ')[0], + state: { + status: 'completed', + title: task, + input: task.startsWith('bash') ? { command: 'pnpm test' } : { filePath: 'src/example.ts' }, + }, + }) + await guard(250) + } + + text(`[dry-run] Applied changes for iteration ${iteration}; checks passed.`) + await guard(250) + + const input = 24000 + iteration * 18000 + const output = 1200 + iteration * 400 + const part = { + type: 'step-finish', + sessionID: sid, + reason: 'stop', + cost: round(0.012 + iteration * 0.004), + tokens: { + total: input + output, + input, + output, + reasoning: 300 * iteration, + cache: { read: 4000 * iteration, write: 0 }, + }, + time: { start: startedAt, end: Date.now(), elapsed: Date.now() - startedAt }, + } + emit('step_finish', part) + + // The simulator occasionally asks for operator input, to exercise HITL. + const needsInput = iteration === 2 && !aborted() + const percent = Math.min(100, Math.round((iteration / total) * 100)) + const p = loopPaths(config.project) + fs.mkdirSync(p.dir, { recursive: true }) + fs.writeFileSync( + p.progress, + JSON.stringify( + { + iteration, + topic: `[dry-run] Advance stage ${iteration}`, + stageId: `stage-${iteration}`, + stageStatus: iteration >= total ? 'done' : 'in_progress', + percent, + nextStage: iteration >= total ? '— (goal complete)' : `Stage ${iteration + 1}`, + summary: `Simulated iteration ${iteration}: touched src/example.ts and ran pnpm test successfully.`, + filesChanged: ['src/example.ts'], + needsInput, + questions: needsInput + ? [{ id: 'q1', text: 'Continue with the current approach or switch to fresh sessions?', options: ['continue', 'fresh'] }] + : [], + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + ) + + return { + sessionID: sid, + exitCode: 0, + error: null, + usage: { input, output, reasoning: 300 * iteration, cacheRead: 4000 * iteration, cacheWrite: 0, total: input + output }, + cost: part.cost, + texts: [`[dry-run] iteration ${iteration} done`], + reasoning: [], + toolCalls: [], + stderr: '', + durationMs: Date.now() - startedAt, + interrupted: aborted(), + } +} + +function round(n) { + return Math.round(n * 10000) / 10000 +} diff --git a/src/state.mjs b/src/state.mjs new file mode 100644 index 0000000..be9f15a --- /dev/null +++ b/src/state.mjs @@ -0,0 +1,182 @@ +import fs from 'node:fs' +import path from 'node:path' +import { EventEmitter } from 'node:events' + +const MAX_LOG_ENTRIES = 4000 +const MAX_LOG_CHARS = 20000 + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +export function timestampId(date = new Date()) { + const p = (n, w = 2) => String(n).padStart(w, '0') + return ( + `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` + + `-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}` + ) +} + +/** + * Central mutable state for a run. Emits: + * 'update' — any part of the state changed (UI re-renders from `snapshot()`) + * 'log' — a new log line { seq, t, level, section, text } + */ +export class RunState extends EventEmitter { + constructor(config, { baseDir } = {}) { + super() + this.setMaxListeners(50) + this.config = config + this.runId = config.runId || timestampId() + this.baseDir = baseDir || path.join(config.project, '.kilocode-loop', 'runs') + this.dir = path.join(this.baseDir, this.runId) + ensureDir(this.dir) + this.logFile = path.join(this.dir, 'events.jsonl') + this.stateFile = path.join(this.dir, 'state.json') + this._logSeq = 0 + this._saveTimer = null + + this.state = { + runId: this.runId, + startedAt: new Date().toISOString(), + endedAt: null, + status: 'starting', // starting|running|paused|waiting-answer|aborted|done|error + project: config.project, + agent: config.agent, + model: config.model || null, + configuredIterations: config.iterations, + hitl: config.hitl, + sessionMode: config.sessionMode, + dryRun: Boolean(config.dryRun), + sessionID: null, + currentIteration: 0, + abortRequested: false, + pauseRequested: false, + goal: null, + stages: [], + iterations: [], + pendingQuestion: null, + totals: { + tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + durationMs: 0, + }, + lastError: null, + } + this.logs = [] + } + + snapshot() { + return { + ...this.state, + goal: this.state.goal, + stages: this.state.stages, + iterations: this.state.iterations, + logs: this.logs.slice(-400), + } + } + + update(patch) { + Object.assign(this.state, patch) + this.emit('update') + this._scheduleSave() + } + + updateIteration(index, patch) { + const list = this.state.iterations + const i = list.findIndex((it) => it.index === index) + if (i === -1) return null + list[i] = { ...list[i], ...patch } + this.emit('update') + this._scheduleSave() + return list[i] + } + + addIteration(iteration) { + this.state.iterations.push(iteration) + this.emit('update') + this._scheduleSave() + } + + log(level, text, { section = null, iteration = null } = {}) { + const entry = { + seq: ++this._logSeq, + t: new Date().toISOString(), + level, + section, + iteration, + text: String(text).slice(0, MAX_LOG_CHARS), + } + this.logs.push(entry) + if (this.logs.length > MAX_LOG_ENTRIES) this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES) + try { + fs.appendFileSync(this.logFile, JSON.stringify(entry) + '\n') + } catch { + /* best effort */ + } + this.emit('log', entry) + return entry + } + + section(title, { iteration = null, level = 'section' } = {}) { + return this.log(level, title, { section: title, iteration }) + } + + _scheduleSave() { + if (this._saveTimer) return + this._saveTimer = setTimeout(() => { + this._saveTimer = null + this.save() + }, 200) + if (this._saveTimer.unref) this._saveTimer.unref() + } + + save() { + try { + const payload = { ...this.state, savedAt: new Date().toISOString() } + fs.writeFileSync(this.stateFile, JSON.stringify(payload, null, 2)) + } catch { + /* best effort */ + } + } + + end(status) { + this.update({ status, endedAt: new Date().toISOString() }) + this.save() + } +} + +export function listRuns(baseDir) { + try { + return fs + .readdirSync(baseDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => { + const file = path.join(baseDir, d.name, 'state.json') + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return null + } + }) + .filter(Boolean) + .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))) + } catch { + return [] + } +} + +export function loadRun(baseDir, runId) { + const file = path.join(baseDir, runId, 'state.json') + return JSON.parse(fs.readFileSync(file, 'utf8')) +} + +export function readRunLog(baseDir, runId, limit = 1000) { + const file = path.join(baseDir, runId, 'events.jsonl') + try { + const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) + return lines.slice(-limit).map((l) => JSON.parse(l)) + } catch { + return [] + } +} diff --git a/src/terminal.mjs b/src/terminal.mjs new file mode 100644 index 0000000..2ff2dd3 --- /dev/null +++ b/src/terminal.mjs @@ -0,0 +1,142 @@ +import readline from 'node:readline' +import { c } from './ansi.mjs' + +/** + * Keyboard controls while a run is attached to a TTY: + * X abort the loop after the current session + * P pause / resume before the next iteration + * Q stop now (kill the current session) + * ? print the shortcut help + * When a question is pending, typed text answers it; number keys pick an option; + * Enter submits, empty Enter skips. + */ +export function installTerminalControls(orchestrator) { + const stdin = process.stdin + if (!stdin.isTTY) return { dispose() {} } + + readline.emitKeypressEvents(stdin) + const wasRaw = stdin.isRaw + stdin.setRawMode(true) + stdin.resume() + + let answerBuf = '' + let qIndex = 0 + let lastRender = '' + + const help = () => { + process.stdout.write( + `\n ${c.bold('Controls')} ${c.cyan('X')} abort after current · ${c.cyan('P')} pause/resume · ${c.cyan('Q')} stop now · ${c.cyan('?')} help\n`, + ) + } + + const renderAnswer = () => { + const pq = orchestrator.state.state.pendingQuestion + if (!pq) { + lastRender = '' + return + } + const questions = pq.questions.length ? pq.questions : [{ text: 'Any corrections before the next iteration?', options: [] }] + const q = questions[Math.min(qIndex, questions.length - 1)] + const line = ` ${c.cyan(`[${qIndex + 1}/${questions.length}]`)} ${q.text}${q.options?.length ? ` ${c.gray('(' + q.options.join(' / ') + ')')}` : ''}\n answer> ${answerBuf}` + const clean = line.length <= lastRender.length ? line : lastRender + line.slice(lastRender.length) + process.stdout.write(`\r\x1b[2K${clean}`) + lastRender = line + } + + const submitAnswer = () => { + const pq = orchestrator.state.state.pendingQuestion + if (!pq) return + const questions = pq.questions + const collected = [] + if (questions.length) { + collected.push({ question: questions[qIndex]?.text || `Question ${qIndex + 1}`, answer: answerBuf.trim() }) + qIndex += 1 + answerBuf = '' + if (qIndex < questions.length) { + lastRender = '' + renderAnswer() + return + } + } else { + collected.push({ question: 'Corrections', answer: answerBuf.trim() }) + answerBuf = '' + } + qIndex = 0 + lastRender = '' + orchestrator.submitAnswers(collected) + process.stdout.write('\n') + } + + const onKey = (str, key) => { + if (key?.ctrl && (key.name === 'c' || key.name === 'd')) { + orchestrator.requestStop() + return + } + + const pending = orchestrator.state.state.pendingQuestion + + if (pending) { + if (key?.name === 'return' || key?.name === 'enter') { + submitAnswer() + return + } + if (key?.name === 'backspace') { + answerBuf = answerBuf.slice(0, -1) + renderAnswer() + return + } + if (key?.name === 'escape') { + answerBuf = '' + lastRender = '' + renderAnswer() + return + } + if (str && str >= ' ' && !key?.ctrl && !key?.meta) { + const qs = pending.questions + const q = qs[qIndex] + if (q?.options?.length && /^[1-9]$/.test(str)) { + const opt = q.options[Number(str) - 1] + if (opt !== undefined) { + answerBuf = opt + renderAnswer() + return + } + } + answerBuf += str + renderAnswer() + return + } + return + } + + const ch = (str || '').toLowerCase() + if (ch === 'x') orchestrator.requestAbortAfterCurrent() + else if (ch === 'p') { + if (orchestrator.state.state.status === 'paused' || orchestrator.state.state.pauseRequested) orchestrator.requestResume() + else orchestrator.requestPause() + } else if (ch === 'q') orchestrator.requestStop() + else if (ch === '?') help() + } + + stdin.on('keypress', onKey) + // Re-render the answer prompt whenever a question appears. + const onUpdate = () => { + if (orchestrator.state.state.pendingQuestion && !lastRender) renderAnswer() + } + orchestrator.state.on('update', onUpdate) + + help() + + return { + dispose() { + stdin.off('keypress', onKey) + orchestrator.state.off('update', onUpdate) + try { + stdin.setRawMode(Boolean(wasRaw)) + } catch { + /* ignore */ + } + stdin.pause() + }, + } +} diff --git a/test/config.test.mjs b/test/config.test.mjs new file mode 100644 index 0000000..9e3e274 --- /dev/null +++ b/test/config.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { parseArgs, loadConfig } from '../src/config.mjs' + +test('parseArgs handles long flags, =value and short flags', () => { + const out = parseArgs(['--goal', 'g.json', '--iterations=7', '-C', '/tmp', '--no-color', '--dry-run']) + assert.equal(out.goal, 'g.json') + assert.equal(out.iterations, 7) + assert.equal(out.project, '/tmp') + assert.equal(out.color, false) + assert.equal(out.dryRun, true) +}) + +test('parseArgs coerces numeric flags', () => { + const out = parseArgs(['--port', '8123', '--context-warn-tokens', '90000']) + assert.equal(out.port, 8123) + assert.equal(out.contextWarnTokens, 90000) +}) + +test('parseArgs rejects unknown flags', () => { + assert.throws(() => parseArgs(['--nope', '1']), /Unknown option/) +}) + +test('loadConfig requires a goal', () => { + assert.throws(() => loadConfig(['--project', '/tmp']), /goal is required/i) +}) + +test('loadConfig validates session-mode and hitl', () => { + assert.throws(() => loadConfig(['--goal-text', 'x', '--session-mode', 'wat']), /session-mode/) + assert.throws(() => loadConfig(['--goal-text', 'x', '--hitl', 'wat']), /hitl/) +}) diff --git a/test/goal.test.mjs b/test/goal.test.mjs new file mode 100644 index 0000000..bf800a9 --- /dev/null +++ b/test/goal.test.mjs @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + classifyProgress, + extractQuestionsFromText, + loadGoal, + normaliseQuestions, + resetProgress, + readProgress, + buildIterationPrompt, +} from '../src/goal.mjs' + +function tmpProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-loop-')) + return dir +} + +test('loadGoal parses markdown checkboxes', () => { + const project = tmpProject() + const file = path.join(project, 'goal.md') + fs.writeFileSync(file, '# My goal\n\nBody text.\n\n- [x] done one\n- [ ] next one\n') + const goal = loadGoal({ project, goal: 'goal.md', goalText: '' }) + assert.equal(goal.title, 'My goal') + assert.equal(goal.stages.length, 2) + assert.equal(goal.stages[0].done, true) + assert.equal(goal.stages[1].title, 'next one') +}) + +test('loadGoal parses json stages', () => { + const project = tmpProject() + const file = path.join(project, 'goal.json') + fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 'a', title: 'A' }] })) + const goal = loadGoal({ project, goal: 'goal.json', goalText: '' }) + assert.equal(goal.stages[0].id, 'a') +}) + +test('loadGoal falls back to a single stage for inline text', () => { + const goal = loadGoal({ project: '/tmp', goal: '', goalText: 'Do the thing' }) + assert.equal(goal.stages.length, 1) + assert.equal(goal.title, 'Do the thing') +}) + +test('classifyProgress prefers explicit percent and normalises questions', () => { + const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] } + const cls = classifyProgress( + { topic: 'topic', percent: 42, stageId: 's2', stageStatus: 'in_progress', nextStage: 'Two', questions: ['Why?'] }, + goal, + 1, + ) + assert.equal(cls.percent, 42) + assert.equal(cls.topic, 'topic') + assert.equal(cls.nextStage, 'Two') + assert.equal(cls.needsInput, true) + assert.equal(cls.questions[0].text, 'Why?') +}) + +test('classifyProgress derives percent from completed stages', () => { + const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] } + const cls = classifyProgress({ stageId: 's1', stageStatus: 'done' }, goal, 1) + assert.equal(cls.percent, 50) + assert.equal(cls.stageStatus, 'done') +}) + +test('classifyProgress clamps out-of-range percentages', () => { + const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }] } + assert.equal(classifyProgress({ percent: 900 }, goal, 1).percent, 100) + assert.equal(classifyProgress({ percent: -5 }, goal, 1).percent, 0) +}) + +test('normaliseQuestions handles strings and objects, filters blanks', () => { + const qs = normaliseQuestions(['a?', { id: 'x', text: 'b?', options: [1, 2] }, { text: ' ' }]) + assert.equal(qs.length, 2) + assert.deepEqual(qs[1].options, ['1', '2']) +}) + +test('extractQuestionsFromText pulls question lines only', () => { + const qs = extractQuestionsFromText(['Line one\ndoes this work?\nhttp://x?y=1\nAnother question?']) + assert.equal(qs.length, 2) + assert.match(qs[0].text, /does this work\?/) +}) + +test('resetProgress + readProgress round-trip', () => { + const project = tmpProject() + resetProgress({ project }, 'run-1', 3) + const p = readProgress({ project }) + assert.equal(p.runId, 'run-1') + assert.equal(p.iteration, 3) +}) + +test('buildIterationPrompt embeds the progress path and answers', () => { + const project = tmpProject() + const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] } + const prompt = buildIterationPrompt({ + config: { project, promptExtra: '' }, + goal, + iteration: 2, + total: 5, + progress: { stageId: 's1', summary: 'prev', iteration: 1 }, + answers: [{ question: 'Q?', answer: 'A!' }], + currentStageIndex: 0, + }) + assert.match(prompt, /iteration 2\/5/i) + assert.match(prompt, /progress\.json/) + assert.match(prompt, /Q:/) + assert.match(prompt, /A!/) +}) diff --git a/test/kilocode.test.mjs b/test/kilocode.test.mjs new file mode 100644 index 0000000..73a2320 --- /dev/null +++ b/test/kilocode.test.mjs @@ -0,0 +1,34 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { buildKiloArgs, describeTool } from '../src/kilocode.mjs' + +test('buildKiloArgs emits json format and the agent', () => { + const args = buildKiloArgs({ agent: 'code-design', auto: true, sessionID: 'ses_1', title: 't', prompt: 'hello' }) + assert.deepEqual(args.slice(0, 3), ['run', '--format', 'json']) + assert.ok(args.includes('--agent')) + assert.equal(args[args.indexOf('--agent') + 1], 'code-design') + assert.ok(args.includes('--auto')) + assert.ok(args.includes('--session')) + assert.equal(args[args.indexOf('--session') + 1], 'ses_1') + assert.equal(args[args.length - 1], 'hello') +}) + +test('buildKiloArgs omits --auto and session when not requested', () => { + const args = buildKiloArgs({ agent: 'code-design', auto: false, prompt: 'p' }) + assert.ok(!args.includes('--auto')) + assert.ok(!args.includes('--session')) + assert.ok(!args.includes('--continue')) +}) + +test('buildKiloArgs can continue the last session', () => { + const args = buildKiloArgs({ agent: 'code-design', auto: true, continueSession: true, prompt: 'p' }) + assert.ok(args.includes('--continue')) +}) + +test('describeTool renders readable section headings', () => { + assert.equal(describeTool({ tool: 'read', state: { input: { filePath: '/a/b/c/deep/file.ts' } } }), 'Read deep/file.ts') + assert.equal(describeTool({ tool: 'bash', state: { input: { command: 'pnpm test --watch' } } }), 'Run pnpm test --watch') + assert.equal(describeTool({ tool: 'grep', state: { input: { pattern: 'TODO' } } }), 'Search /TODO/') + assert.equal(describeTool({ tool: 'mystery', state: { title: 'Do a thing' } }), 'Do a thing') +}) diff --git a/test/reports.test.mjs b/test/reports.test.mjs new file mode 100644 index 0000000..ffddf07 --- /dev/null +++ b/test/reports.test.mjs @@ -0,0 +1,75 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { formatCost, formatNumber, formatDuration, progressBar, renderConsoleReport, renderMarkdownReport, contextState } from '../src/reports.mjs' + +test('formatNumber uses en-US grouping', () => { + assert.equal(formatNumber(1234567), '1,234,567') +}) + +test('formatCost keeps precision for tiny values', () => { + assert.equal(formatCost(0), '$0.0000') + assert.match(formatCost(0.002106102), /^\$0\.00211$/) + assert.match(formatCost(1.5), /^\$1\.5000$/) +}) + +test('formatDuration renders mm:ss and hours', () => { + assert.equal(formatDuration(65_000), '01:05') + assert.equal(formatDuration(3_725_000), '1h 02m') +}) + +test('progressBar clamps', () => { + assert.equal(progressBar(0, 4), '░░░░') + assert.equal(progressBar(100, 4), '████') + assert.equal(progressBar(50, 4), '██░░') + assert.equal(progressBar(999, 4), '████') +}) + +test('contextState classifies thresholds', () => { + assert.equal(contextState(10, 100), 'ok') + assert.equal(contextState(80, 100), 'warn') + assert.equal(contextState(120, 100), 'over') +}) + +const report = { + runId: 'run', + iteration: 2, + total: 5, + status: 'completed', + topic: 'Refactor settings', + stageTitle: 'Stage 2', + stageId: 's2', + stageStatus: 'in_progress', + percent: 42, + nextStage: 'Stage 3', + summary: 'Changed things and verified.', + questions: [], + tokens: { input: 1000, output: 200, reasoning: 10, cacheRead: 500, total: 1200 }, + cost: 0.05, + contextSize: 1200, + contextState: 'ok', + contextWarnTokens: 150000, + durationMs: 65_000, + filesChanged: ['a.ts', 'b.vue'], + sessionID: 'ses_1', + error: null, +} + +test('renderConsoleReport contains the key report fields', async () => { + const { setColorEnabled } = await import('../src/ansi.mjs') + setColorEnabled(false) + const text = renderConsoleReport(report, { cost: 0.2 }) + assert.match(text, /Iteration 2\/5/) + assert.match(text, /Refactor settings/) + assert.match(text, /42%/) + assert.match(text, /Stage 3/) + assert.match(text, /1,200/) +}) + +test('renderMarkdownReport emits a structured document', () => { + const md = renderMarkdownReport(report) + assert.match(md, /# Iteration 2\/5/) + assert.match(md, /## Files changed/) + assert.match(md, /`a\.ts`/) + assert.match(md, /## Summary/) +})