v0.9.0
A run can be created, written, configured, started, corrected and read from the browser. The panel
was already the run's control surface once a run existed; what it grew here is everything before
that and everything the CLI could do that it could not. The walkthrough is in
the guide; what stays outside the browser
is bringing the panel up.
Added
- A milestone can be added to an existing run, mid-run included.
milestoner add [--title <text>]
appends one pending milestone - the next id after the highest instate.json, a prompt skeleton
in.milestoner/prompts/- under the state lock, and prints the id and the prompt path. Until
now the count chosen atinitwas final short of hand-editingstate.json, a file the engine
owns. - The append is safe while a runner is alive: the runner picks its next milestone from a fresh
state load on every loop pass, so an added milestone is reached when its turn comes, with no
restart. Appending to a completed run clearsrunComplete- a run that gains a milestone is a
run again. A prompt file already sitting at the skeleton's name is kept, so the prompt can be
written before the slot is added. - The panel grew the same control: an Add a milestone card under the milestone list, posting
toPOST /api/milestone/add({ title }optional) behind--write, the key, theHost
allowlist and theOrigincheck like every mutation. The reply names the new id, and the card
appears on the next refresh; both front ends call the one engine primitive. - A run can be created from the panel. The hub grows a New run card - directory, optional run
name, milestone count - posting toPOST /api/init, which calls the sameinit()the CLI does,
so the scaffold and its refusals are the command's. The new project is recorded in
~/.milestoner/projects.json, so it joins the hub listing on the next refresh with no CLI command
run anywhere. POST /api/initvalidates its body beforeinit()sees it: the path must be absolute and an
existing directory (a relative one would resolve against the panel daemon's working directory, and
a missing one is refused rather than created),milestonestakes the CLI's 1-99 bounds, and
forcemust be an explicittrue. It is behind--write, the key, theHostallowlist and the
Origincheck like every other mutation, and it is a machine-panel route: a panel serving one
project answers 404. Only an existing-config refusal reveals the force checkbox; a protocol naming
another run (D-030) is refused even with force and says which run it names, because force cannot
answer it. The reasoning for accepting a filesystem path over HTTP is D-038..milestoner/config.jsoncan be read and edited from the panel. The per-run view carries the whole
document in a text box, fed byGET /api/configand saved byPOST /api/config, so every key -
theinfrathresholds,fallbackAgents,liveness,environment, the agent command - is
reachable without leaving the browser. A runner that is already going read its config at startup,
so an edit applies to the next one; the card says so rather than blocking the edit.- A save is validated by the loader itself: the submitted text is parsed and put through the same
checksloadConfigruns on every runner start, and only a document that passes is written, through
the same atomic write every other engine write uses. A refusal carries the loader's own sentence
(missing required field "agent", or the JSON parser's position) and leaves the file byte for byte
as it was, so nothing that would stop the next runner from starting can be saved from the panel.
projectRootis dropped rather than written, asinithas always left it out. - A model per milestone:
modelsin.milestoner/config.jsonmaps a milestone id to the model its
session runs on ({"M03": "opus"}), so a plan can spend a cheap model on the mechanical
milestones and a stronger one on the hard ones. It is resolved at each session launch, not once
at startup, so an edit mid-run applies from the next session.--modeloverrides the whole map;
a fallback agent keeps its ownmodel, because model names are not interchangeable across
agents.milestoner lintwarns (orphan-model) about amodelskey naming no milestone in
state.json, which is otherwise a model silently never used. - A model field on every milestone card, holding that milestone's entry in the
modelsmap and empty
when it has none. Saving reads the config, changes that one key and sends the whole document back
through the same validated endpoint; clearing the field removes the entry and the milestone goes
back to the agent's own model. - The panel starts a run with the same options the CLI takes.
POST /api/run/startaccepts
milestone,once,maxAttemptsandmodelbesidenoLintand translates each to its flag on
the spawned runner; the start control grows a collapsed options row with a milestone picker built
from the run's own ids, a "one session, then stop" box, an attempts field and a model field, all
optional, posting only what was filled in. "Unstick the environment" gains a seconds input that
overridesenvironment.attendSecondsfor that one run of the adapter. - Start options are validated in the panel's process before anything is spawned: an unknown or
emptymilestone, amaxAttemptsthat is not a positive integer, an empty or non-stringmodel
and a non-booleanonceare refused with a message naming the field and no runner started. The
runner is spawned detached with its output discarded, so a flag it would reject would otherwise
fail where nobody can see it. - The machine panel lists every project on the machine, not only the ones whose runner is alive or
started while the panel was up. Every command that works inside a project records its directory in
~/.milestoner/projects.json(initincluded), and the hub summarises the ones the registry has
never heard of from their ownstate.json, reportedunknownrather thangonebecause nothing
died there. They resolve for every control, so a run can be started, steered or unblocked from the
browser after a reboot. Writing the file is best-effort, a corrupt one is treated as empty, and an
entry whose directory is gone is skipped and left in place. - The milestone prompts and the protocol can be written from the panel. Each milestone card carries
an editor for its prompt file, collapsed behind an "edit the prompt" link, fed by
GET /api/prompt?name=<file>and saved byPOST /api/prompt; a Protocol card holds
.milestoner/protocol.mdthroughGETandPOST /api/protocol. Nothing structural is checked
before writing - both files are hand-written prose by design, so the lint card is the feedback
rather than a write gate, and it refreshes on a save so filling in a skeleton visibly clears its
template-residuefindings. A prompt is reachable only by a name some milestone'spromptfield
carries: path separators, a missing.mdor a name no milestone owns are refused, read and write
alike. Writes are atomic and behind--write, the key and theOrigincheck like every other
mutation; an edit applies to the next session launched, as steering does, and the editors say so. - Direct tests for the
api.tshandlers, which the panel's whole write surface goes through and
which until now were only exercised indirectly over HTTP. - The report served by the panel carries a link back to the view it came from, and one line saying
what it is: the run's archival snapshot, the same self-contained filemilestoner reportwrites,
where the panel is the live view. The link carries exactly what the report's own URL carried - the
run on a machine panel, the key only when the caller used one - so a page reached through the
cookie--opensets never gains a key in its history. The file on disk passes no such link and so
carries none: one renderer, and only the served consumer knows a panel exists. - Every card on the panel says what it is for, in one visible line under its heading: the verdict
banner spells out what alive, slow, hung and gone mean, the lint card that error-level findings on
pending milestones refuse a start, steering that a correction is read by the next session launched
and not by the one running, the interventions card that its lines are outside actions on the run,
the engine log which file it is reading, and the report link what a self-contained report is good
for. Every consequential control carries a tooltip naming what it does and what it costs - kill
spends an attempt and brings the runner straight back, "start anyway" skips the gate and leaves
the findings unfixed, stop costs nothing. The subtitles are visible text rather than tooltips
because a tooltip does not exist on a touch screen.
Fixed
-
An empty supervisor log no longer renders as two interventions.
supervisor-log.mdopens with a
title and a backtick-quoted format line, written byinitbefore anything has ever happened, and
the panel's INTERVENTIONS card andstatus --json'srecentInterventionsboth tailed the file
raw and served them. Reading the log is now one shared function that drops its own header, used by
the panel,statusand both reports, and a run nobody has intervened in says so. -
The milestone card a session is working on shows that session. The card body rendered only graded
attempt history, so while attempt 1 ran the page contradicted itself: the verdict banner said
"Working on M01, attempt 1" while the card below read "IN PROGRESS", "no attempts used" and "Not
started yet." Now, when the pulse names the milestone and the runner is alive, the card carries a
live block - when the session started, the agent when the pulse names one, a "watch the live
transcript" link - and its counter reads "attempt 1 of 3 running"; with the runner gone or on
another milestone it says exactly that instead of claiming a session, and the graded history keeps
its table below either way. "Not started yet." is reserved for a pending milestone with no history
and no live session. The report has the matching fix: an in-progress milestone with nothing graded
yet reads "in progress since , no session graded yet" rather than "0 sessions" and "no
evidence recorded" - worded from state alone, because a static report cannot vouch for a live
session. -
The timeline says what its bars are worth. A bar's length answered nothing before: there was no
axis, no scale and no label. Each track now carries its sessions' durations on the right, the
timeline carries a five-tick time axis under it, and a scale line names the extent it spans
("Scale: 2026-08-22 19:36 to 2026-08-22 20:07, 31m end to end"). A session whose record has no
usable end is drawn as an open-ended bar reading "no end recorded" rather than being given a
length; the wall-clock tile reads "-" rather than a figure when no session has run at all. -
The report's headline no longer says "in progress" for a run that never launched. A scaffolded run
with zero sessions reads "not started yet" and its timeline says there is nothing to place on the
clock. The four states - not started, in progress, blocked at<id>, run complete - come from
summarize, the same verdictsstatusand the panel read, rather than from a second reading of
the milestone list. -
The hub is reachable on a machine with exactly one project. It opens that run on arrival, as
before, but "all runs" now says so in the URL and stays on the hub instead of bouncing straight
back into the only run - which left the hub, and now the new-run form on it, unreachable. -
GET /api/transcriptwith nonameanswers 404 rather than 500. An empty name resolved to the
logs directory itself, and reading a directory as a file threwEISDIR, which the panel returned
as a filesystem error message.