diff --git a/kaievolve/explore.py b/kaievolve/explore.py
index e800057..b7f9a38 100644
--- a/kaievolve/explore.py
+++ b/kaievolve/explore.py
@@ -42,6 +42,31 @@ def read_json(path: Path) -> Optional[Dict[str, Any]]:
return None
+def read_progress(run_dir: Path) -> List[Dict[str, Any]]:
+ """The live per-iteration progress feed (``progress.jsonl``), if the run wrote
+ one. Each row: ``{iter, score, best, program_id, model, ts}``. Updates every
+ iteration (unlike checkpoints), so monitors/viewer can stream the trajectory
+ in real time. Empty list if the run predates the feed or hasn't started."""
+ return read_jsonl(run_dir / "progress.jsonl")
+
+
+def progress_trajectory(rows: List[Dict[str, Any]]):
+ """Turn progress rows into ``(best_points, raw_points)`` for svg.trajectory:
+ best-so-far step line and each attempt's raw score, keyed by iteration."""
+ best, raw = [], []
+ for r in rows:
+ it = r.get("iter")
+ if it is None:
+ continue
+ b = r.get("best")
+ s = r.get("score")
+ if isinstance(b, (int, float)):
+ best.append((int(it), float(b)))
+ if isinstance(s, (int, float)):
+ raw.append((int(it), float(s)))
+ return best, raw
+
+
def latest_checkpoint(run_dir: Path) -> Optional[Path]:
ck = run_dir / "checkpoints"
if not ck.is_dir():
@@ -275,7 +300,18 @@ def run_state(run_dir: Path) -> RunState:
if pts:
st.iteration = max(st.iteration, pts[-1][0])
- mtimes = [p.stat().st_mtime for p in (ulog, ckpt) if p and p.exists()]
+ # Live override: progress.jsonl updates every iteration (checkpoints lag),
+ # so prefer it for the current iteration / best / curve when present.
+ prog_path = run_dir / "progress.jsonl"
+ prog = read_jsonl(prog_path)
+ if prog:
+ st.iteration = max((r.get("iter", st.iteration) for r in prog), default=st.iteration)
+ bests = [r.get("best") for r in prog if isinstance(r.get("best"), (int, float))]
+ if bests:
+ st.best_score = max(bests)
+ st.curve = bests # each row's 'best' is already best-so-far
+
+ mtimes = [p.stat().st_mtime for p in (ulog, ckpt, prog_path) if p and p.exists()]
if mtimes:
st.stale_s = time.time() - max(mtimes)
return st
diff --git a/kaievolve/process_parallel.py b/kaievolve/process_parallel.py
index 636497a..81c87bc 100644
--- a/kaievolve/process_parallel.py
+++ b/kaievolve/process_parallel.py
@@ -1418,6 +1418,45 @@ async def run_evolution(
f"{child_program.id}"
)
+ # Live progress feed: one JSON line per completed iteration so
+ # `kai monitor` and the web viewer can stream the trajectory in
+ # real time (checkpoints only land every checkpoint_interval).
+ # Wrapped so telemetry can never break the evolution loop.
+ try:
+ import json as _json
+ import os as _os
+ import time as _time
+
+ _best = self.database.get_best_program()
+ _bscore = (
+ _best.metrics.get("combined_score") if _best and _best.metrics else None
+ )
+ _cscore = (
+ child_program.metrics.get("combined_score")
+ if child_program.metrics
+ else None
+ )
+ _model = None
+ _idx = result.generated_by_model_idx
+ if _idx is not None and 0 <= _idx < len(self.config.llm.models):
+ _model = self.config.llm.models[_idx].name
+ with open(_os.path.join(self.output_dir, "progress.jsonl"), "a") as _pf:
+ _pf.write(
+ _json.dumps(
+ {
+ "iter": completed_iteration,
+ "score": _cscore,
+ "best": _bscore,
+ "program_id": child_program.id,
+ "model": _model,
+ "ts": _time.time(),
+ }
+ )
+ + "\n"
+ )
+ except Exception:
+ pass
+
# Early stopping check
if (
early_stopping_active
diff --git a/kaievolve/viewer/server.py b/kaievolve/viewer/server.py
index 79e1c47..6c8f231 100644
--- a/kaievolve/viewer/server.py
+++ b/kaievolve/viewer/server.py
@@ -14,6 +14,10 @@
* ``/setup/{label}/run/{idx}`` run dashboard: trajectory chart + a summary
panel beside it, then a full-width clickable step table. Clicking a step opens
a right-side **drawer** (its notes / diff / solution viz) without leaving the page.
+* ``/setup/{label}/run/{idx}/live.json`` live state of an active run (current
+ iteration, best, cost, calls, freshly-rendered chart). The dashboard polls this
+ every 3s while the run is active and updates the chart/stats in place; the
+ trajectory streams per-iteration from the run's ``progress.jsonl`` feed.
* ``/setup/{label}/run/{idx}/step/{n}/detail`` the drawer fragment for one step
(notes + measurements + diff + a viz iframe); fetched by the drawer JS.
* ``/setup/{label}/run/{idx}/step/{n}/viz`` one step's solution viz as a
@@ -146,6 +150,11 @@
.detail { min-width:0; }
.chip { display:inline-block; font-size:0.74rem; padding:1px 7px; border-radius:9px;
background:#eef4fb; color:var(--accent); margin-left:6px; }
+ /* live-run indicator (pulsing while a run is active) */
+ .livedot { font-size:0.7rem; font-weight:600; color:var(--pos); margin-left:10px;
+ vertical-align:middle; animation:livepulse 1.4s ease-in-out infinite; }
+ .livedot.done { color:var(--muted); animation:none; }
+ @keyframes livepulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* back button (top of every drilled-in page) */
.backbtn { display:inline-flex; align-items:center; gap:5px; font-size:0.82rem;
color:var(--muted); margin-bottom:4px; }
@@ -293,26 +302,46 @@
_DASHBOARD = """{% extends "base" %}{% block title %}{{ short }} run {{ seed }} - kai{% endblock %}
{% block content %}
-
{{ short }} · run {{ seed }}
+
{{ short }} · run {{ seed }}
+ ● live
{{ task }}{% if blurb %} — {{ blurb }}{% endif %}
- {{ chart | safe }}
+
{{ chart | safe }}
Line = best score so far · hollow dots = a new best · faint dots =
- each attempt{% if has_std %} · whiskers = ±1σ score noise{% endif %}.
+ each attempt{% if has_std %} · whiskers = ±1σ score noise{% endif %}{% if has_progress %} ·
+ streaming live every iteration{% endif %}.
-
best score{{ best }}
-
steps{{ steps_n }}
-
cost{{ cost }}
-
AI calls{{ calls }}
+
best score{{ best }}
+
{% if is_active %}iteration{% else %}steps{% endif %}{{ steps_n }}
+
cost{{ cost }}
+
AI calls{{ calls }}
models: {% for m in models %}{{ m.name }} {{ m.count }}{% if not loop.last %} · {% endif %}{% endfor %}