Initial commit

This commit is contained in:
Vitali sharp8n 2026-09-13 13:46:28 +03:00
parent 556255d076
commit 602a973fc3
26 changed files with 4189 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
runs/
.kilocode-loop/
*.log
.DS_Store
.env

244
README.md Normal file
View file

@ -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 — <stage> ───────────────────────────────┐ │
│ │ ▸ 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 <http://127.0.0.1:7999>.
## 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 `<project>/.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
```
<project>/.kilocode-loop/
progress.json # the agent's machine-readable report
answers.json # operator answers from HITL
runs/<runId>/
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 <file> .json goal or .md checklist
--goal-text "<text>" inline goal
-n, --iterations <N> loop count (default 5)
Execution
-C, --project <dir> repository the agent works in (default cwd)
-a, --agent <name> Kilo agent (default code-design)
-m, --model <id> model override (provider/model)
--variant <name> reasoning variant (e.g. high, max)
--thinking capture reasoning parts
--auto / --no-auto auto-approve permissions (default: auto)
--session-mode <mode> continue (one growing session) | fresh
--hitl <mode> always | on-question | off
--max-iteration-minutes <n>
--context-warn-tokens <n>
--prompt-extra <file> extra instructions for every iteration
Dashboard
-p, --port <n> default 7999
--host <host> default 127.0.0.1
--keep-open keep the dashboard after the loop
Utility
--dry-run simulate the agent (no API calls)
--resume <runId> reuse a previous run's session + stage progress
--run-id <id>
--quiet suppress the live console stream
--no-color
```
Config can also live in `<project>/.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 <id>` 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)
```

76
bin/kilocode-loop.mjs Normal file
View file

@ -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()

View file

@ -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-*."
}
]
}

10
examples/goal.example.md Normal file
View file

@ -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

891
package-lock.json generated Normal file
View file

@ -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"
}
}
}

21
package.json Normal file
View file

@ -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"
}
}

288
public/app.js Normal file
View file

@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}<small>%</small>`,
bar: percent,
},
{ k: 'Iteration', v: `${done}<small> / ${s.configuredIterations ?? s.iterations.length}</small>` },
{ k: 'Status', v: `<span style="font-size:14px">${esc(s.status)}</span>` },
{ k: 'Tokens', v: `${fmt(totals.tokens?.total)}<small> in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}</small>` },
{ k: 'Cost', v: fmtCost(totals.cost) },
{
k: 'Context',
v: `${fmt(context)}<small> tokens</small>`,
},
]
$('stats').innerHTML = cards
.map(
(c) => `<div class="stat"><div class="k">${c.k}</div><div class="v">${c.v}</div>${
c.bar !== undefined ? `<div class="bar"><i style="width:${Math.max(0, Math.min(100, c.bar))}%"></i></div>` : ''
}</div>`,
)
.join('')
void statuses
}
// ---------- iterations ----------
function renderIterations(s) {
const box = $('iterations')
if (!s.iterations.length) {
box.innerHTML = '<div class="empty">No iterations yet.</div>'
return
}
box.innerHTML = s.iterations
.map((it) => {
const pct = it.percent ?? 0
const status = it.status || 'pending'
const questions = (it.questions || []).length
? `<ul class="qlist">${it.questions.map((q) => `<li>${esc(q.text)}</li>`).join('')}</ul>`
: ''
const files = (it.filesChanged || []).length
? `<div class="files">${it.filesChanged.slice(0, 8).map((f) => `<span class="chip">${esc(f)}</span>`).join('')}${
it.filesChanged.length > 8 ? `<span class="chip">+${it.filesChanged.length - 8}</span>` : ''
}</div>`
: ''
return `<div class="iter ${esc(status)}">
<div class="head">
<span class="num">#${it.index}</span>
<span class="topic">${esc(it.topic || 'running…')}</span>
<span class="badge ${esc(status)}">${esc(status)}</span>
</div>
<div class="meta">
<span>${fmt(it.tokens?.total)} tok</span>
<span>${fmtCost(it.cost)}</span>
<span>ctx ${fmt(it.contextSize)}</span>
<span>${fmtDur(it.durationMs)}</span>
${it.toolCount ? `<span>${it.toolCount} tools</span>` : ''}
</div>
<div class="stage"><span class="lbl">Stage:</span> ${esc(it.stageTitle || '')} <span class="lbl">[${esc(it.stageStatus || 'n/a')}]</span></div>
<div class="stage"><span class="lbl">Next:</span> ${esc(it.nextStage || '')}</div>
${it.summary ? `<div class="summary">${esc(it.summary)}</div>` : ''}
${files}
${questions}
<div class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i></div>
</div>`
})
.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' ? '' : `<span class="t">${time}</span>`
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) => `<div class="q" data-i="${i}">
<label>${esc(q.text)}</label>
${
q.options?.length
? `<div class="opts">${q.options
.map((o) => `<button type="button" class="opt" data-val="${esc(o)}">${esc(o)}</button>`)
.join('')}</div>`
: ''
}
<input type="text" placeholder="Type your answer (Enter to submit)" />
</div>`,
)
.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()

57
public/index.html Normal file
View file

@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kilocode-loop</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo"></span>
<div>
<div class="title">kilocode-loop</div>
<div class="sub" id="run-meta">connecting…</div>
</div>
</div>
<div class="spacer"></div>
<div class="status" id="status-pill">starting</div>
<div class="controls">
<button id="btn-pause" title="Pause before the next iteration (P)">Pause</button>
<button id="btn-abort" title="Stop after the current session finishes (X)">Abort after session</button>
<button id="btn-stop" class="danger" title="Kill the current session now (Q)">Stop now</button>
</div>
</header>
<section class="stats" id="stats"></section>
<main>
<section class="panel timeline-panel">
<h2>Iterations <span class="hint" id="iter-hint"></span></h2>
<div id="iterations" class="iterations"></div>
</section>
<section class="panel console-panel">
<h2>
Live session
<span class="hint">sections mark the current action · <b>X</b> abort after current · <b>P</b> pause · <b>Q</b> stop</span>
</h2>
<div id="log" class="log"></div>
</section>
</main>
<div class="modal hidden" id="modal">
<div class="modal-card">
<h3>Operator input required</h3>
<p class="muted" id="modal-sub"></p>
<div id="modal-questions"></div>
<div class="modal-actions">
<button id="btn-skip">Skip all</button>
<button id="btn-submit" class="primary">Submit</button>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

245
public/styles.css Normal file
View file

@ -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; }
}

37
src/ansi.mjs Normal file
View file

@ -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
}

209
src/config.mjs Normal file
View file

@ -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 > <project>/.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 <file> or --goal-text "<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 <file> [options]
kilocode-loop --goal-text "<goal>" -n 5 --project /path/to/repo
Goal:
--goal <file> Goal file (.json with stages, or .md with "- [ ]" checkboxes)
--goal-text "<text>" Inline goal description (single-stage)
--iterations, -n <N> Number of loop iterations (default ${DEFAULTS.iterations})
--prompt-extra <file> Extra instructions appended to every iteration prompt
Execution:
--project, -C <dir> Repository the agent works in (default cwd)
--agent, -a <name> Kilo agent to use (default ${DEFAULTS.agent})
--model, -m <id> Model override (provider/model)
--variant <name> 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 <mode> continue (one growing session) | fresh (new session per iteration)
--hitl <mode> always | on-question | off (human-in-the-loop)
--max-iteration-minutes <n> Hard timeout per iteration (0 = none)
--context-warn-tokens <n> Warn when the context passes this size (default ${DEFAULTS.contextWarnTokens})
Dashboard:
--port, -p <n> Express dashboard port (default ${DEFAULTS.port})
--host <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 <runId> Resume/continue a previously saved run's session
--run-id <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()

59
src/console.mjs Normal file
View file

@ -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`)
}
}

51
src/git.mjs Normal file
View file

@ -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
}
}

296
src/goal.mjs Normal file
View file

@ -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": "<short topic of what you did>",
"stageId": "<id of the stage you worked on>",
"stageStatus": "in_progress" | "done" | "blocked",
"percent": <overall goal completion, 0-100>,
"nextStage": "<what the next iteration should do>",
"summary": "<2-4 sentences: what changed and how it was verified>",
"filesChanged": ["<path>", "..."],
"needsInput": <true|false>,
"questions": [{ "id": "q1", "text": "<question for the operator>", "options": ["<optional choice>"] }]
}
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)
}

204
src/kilocode.mjs Normal file
View file

@ -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)
})
})
}

489
src/orchestrator.mjs Normal file
View file

@ -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 }

152
src/reports.mjs Normal file
View file

@ -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 }

143
src/server.mjs Normal file
View file

@ -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)),
})
})
})
}

115
src/simulator.mjs Normal file
View file

@ -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
}

182
src/state.mjs Normal file
View file

@ -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 []
}
}

142
src/terminal.mjs Normal file
View file

@ -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()
},
}
}

32
test/config.test.mjs Normal file
View file

@ -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/)
})

110
test/goal.test.mjs Normal file
View file

@ -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!/)
})

34
test/kilocode.test.mjs Normal file
View file

@ -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')
})

75
test/reports.test.mjs Normal file
View file

@ -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/)
})