Initial commit
This commit is contained in:
parent
556255d076
commit
602a973fc3
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
runs/
|
||||
.kilocode-loop/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
244
README.md
Normal file
244
README.md
Normal 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
76
bin/kilocode-loop.mjs
Normal 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()
|
||||
21
examples/goal.example.json
Normal file
21
examples/goal.example.json
Normal 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
10
examples/goal.example.md
Normal 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
891
package-lock.json
generated
Normal 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
21
package.json
Normal 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
288
public/app.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
57
public/index.html
Normal 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
245
public/styles.css
Normal 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
37
src/ansi.mjs
Normal 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
209
src/config.mjs
Normal 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
59
src/console.mjs
Normal 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
51
src/git.mjs
Normal 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
296
src/goal.mjs
Normal 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
204
src/kilocode.mjs
Normal 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
489
src/orchestrator.mjs
Normal 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
152
src/reports.mjs
Normal 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
143
src/server.mjs
Normal 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
115
src/simulator.mjs
Normal 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
182
src/state.mjs
Normal 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
142
src/terminal.mjs
Normal 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
32
test/config.test.mjs
Normal 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
110
test/goal.test.mjs
Normal 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
34
test/kilocode.test.mjs
Normal 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
75
test/reports.test.mjs
Normal 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/)
|
||||
})
|
||||
Loading…
Reference in a new issue