Skip to content

feat: the terminal answers what the operator came to ask - #7

Merged
lntvan166 merged 3 commits into
mainfrom
feat/operator-facing-output
Aug 20, 2026
Merged

feat: the terminal answers what the operator came to ask#7
lntvan166 merged 3 commits into
mainfrom
feat/operator-facing-output

Conversation

@lntvan166

@lntvan166 lntvan166 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Six measured complaints from running paddock on a second machine, all the same
complaint underneath: paddock knew the answer and printed something else.

What changed

An unnamed agent gets a familiar name. herdr does not require a name, and
the fallback was pane_id — so a machine where nothing had been named rendered
a dashboard of coordinates like w3:p1. Correct, and useless.

This reverses the letter of a rule the project has had from the start, so the
reasoning is worth checking. The failure that rule protected against was two
rows rendering identically, and basename(cwd) was banned because on its
own it cannot promise otherwise. toAgents supplies the promise — project,
then project p1, then project w1:p1, climbing only when the rung below is
ambiguous — so the guarantee holds and the ban is no longer what enforces it.
Recorded as decision 15; gotchas.md amended rather than contradicted.

A label that mutates as agents come and go was the accepted cost. AgentChip
renders the name alone, so the alternative was an idle section of five
identical chips — five controls with no way to know which one you are about to
tap.

Backticks delimit what you can type. Hints named commands in three
conventions at once (bare, 'quoted', `backticked`), which is the same as
naming them in none. Colour only decorates: term.test.ts asserts that
stripping every escape returns the plain line, so NO_COLOR, a pipe, a CI log
and a screenshot in an issue all carry the same information. Decided per
stream, so paddock status > out.txt cannot put escape bytes in the file
while stderr keeps its colour.

Boot leads with the URL. It printed four lines of bookkeeping first. The URL
cannot simply move up — the port is not bound until later, and a URL printed
before the listener exists is a lie every time the bind hits EADDRINUSE — so
the diagnostics moved instead. Nothing is dropped: after end() every call site
logs individually exactly as before, which is what the no-swallowing rule
depends on. A broken shape is deliberately never folded in.

A new release reaches the terminal. latestKnown has ridden the heartbeat
to HostHeader since the check existed, dim on purpose. But paddock is started
in a terminal, and nothing there ever mentioned a new version. Announced when
the answer arrives rather than at banner time, since the check is async and
caches for a day.

cloudflared stops fighting the tunnel display for stdout. startTunnel
logged every line for the life of the child while draw() repainted with
ESC[H ESC[J once a second, so lines flashed and were erased. The lines are
held, not silenced — cloudflared's stderr is the only place a tunnel failure
explains itself, so the tail prints on every failing path, where cloudflared exited N — the URL is gone used to be the entire explanation. Teardown now
clears the block: ^C signals the whole process group and reaches cloudflared
first, so the display was repainting tunnel up over a tunnel already dying.

README: paddock tunnel moved into the install flow, and the stale
"protocol 19 / herdr 0.8.0 / refuses to start on a mismatch" claim corrected —
all three went stale in v0.7.0, which made the check directional.

Verification

make check, make check-clean, 849 pass / 0 fail.

Verified live against herdr 0.8.2: the boot block collapses to one herdr: line
above the URL, and the previously unnamed agent in this repo now labels from its
directory instead of a pane id. Colour verified under a pty and absent under a
pipe.

Not verified

paddock tunnel has not been run live — it publishes a public URL. The four
new tests cover buffering, the 50-line cap, the failure dump and the teardown
clear, but not the real ^C-reaches-the-process-group timing that produced the
original report. Being tested on a second machine before this merges.

Also flagged, deliberately not fixed here: herdr: new agent detected, resubscribing fires after boot having already reported its pane count, which
suggests a redundant supervisor round-trip. Unrelated to this branch.

🤖 Generated with Claude Code


Added after review: a release nobody hears about is not a release

Two faults behind one symptom — an operator a version behind did not know.

The check fired once, at boot. latestKnown was frozen from that moment, so
a paddock started on Monday reported Monday's view of the world on Friday.
paddock start and a dashboard left open on a phone are the two documented ways
to use this, and both are exactly the long-lived case that could never learn.

scheduleUpdateChecks re-asks hourly. The timer is not the rate limit
checkForUpdate's 24h on-disk cache is, unchanged — so GitHub is still contacted
at most once a day and PADDOCK_NO_UPDATE_CHECK=1 still disables everything. 23
of every 24 ticks are a small local file read. Deliberately not a 24h timer: a
tick the same length as the cache window drifts against it, and landing a
millisecond early means waiting nearly two days.

The notice was one dim 10px line in HostHeader, among other dim metadata,
on the recorded reasoning that paddock update is "not an alarm". The reasoning
was right and the placement was wrong — a line the colour of its neighbours is
skipped, not read. Now ReleaseBanner, and the old line is gone rather than
kept beside it.

Dismissable, which no other banner here is, because of an asymmetry unique to
this one: you cannot act on it from the device you are reading it on.
paddock update runs on the host, not the phone. A notice you can neither act on
nor silence is how a dashboard teaches you to ignore banners — and the next one is
the connection banner, which does mean an agent's state may be wrong right now.
Dismissal is keyed by version, not a boolean, or the first dismissal would be
permanent and the feature would quietly stop existing.

HostHeader loses latestKnown as a prop. Its doc argued the prop was required
so dropping it would be a type error — a good guard, but a required prop nothing
renders is the same trap pointing the other way. The guarantee moves to
App.tsx, the one caller with the value.

Decision 16. 861 pass / 0 fail.

lntvan166 and others added 2 commits August 20, 2026 19:02
Five measured complaints, all the same complaint: paddock knew the answer and
printed something else.

An agent herdr never named was labelled `w3:p1`. Correct, and useless — it
identifies the pane and says nothing about the work, so a machine where nothing
had been named rendered a dashboard of coordinates. The fallback is now
basename(cwd), which reverses the letter of a rule this project has had from
the start, so read what that rule protected: the failure was two rows rendering
IDENTICALLY, and cwd was banned because alone it cannot promise otherwise.
toAgents supplies the promise — `project`, then `project p1`, then
`project w1:p1`, climbing only when the rung below is ambiguous — so the
guarantee holds and the ban is no longer what enforces it. It decides across
the whole list because uniqueness is not a property of one row, and recomputes
every reconcile so a suffix appears and leaves with the agent that caused it. A
label that mutates was the accepted cost: AgentChip renders the name ALONE, so
the alternative was an idle section of five identical chips, five controls with
no way to know which one you are about to tap. Decision 15.

Hints named commands in three conventions at once — bare, 'quoted', `backticked`
— which is the same as naming them in none, because no one of them was reliable
enough to read as a delimiter. Backticks everywhere now, and colour only
DECORATES: term.test.ts asserts that stripping every escape returns the plain
line, so NO_COLOR, a pipe, a CI log and a screenshot in an issue all carry the
same information. Colour is decided per stream, not per process, because
`paddock status > out.txt` must not put escape bytes in the file while stderr
keeps its own. Applied at the print boundary rather than threaded through
portInUseMessage, herdrUnreachableMessage and shapeMessage, whose whole value is
that they can be asserted without a tty.

Boot printed four lines of bookkeeping and then the URL. Every one of those
lines is true and worth logging; none is what someone starting a dashboard is
looking for. The URL cannot simply move up — the port is not bound until later,
and a URL printed before the listener exists is a lie every time the bind hits
EADDRINUSE — so the diagnostics moved instead, collected during boot and
emitted as one line above the banner. Nothing is dropped: after end() every call
site logs individually exactly as before, which is what the no-swallowing rule
depends on. A `broken` shape is deliberately never folded in; compressing the
loudest failure this project has into a clause would put it in the quietest
typography it owns.

A new release reached the dashboard and never the terminal. latestKnown has
ridden the heartbeat to HostHeader since the check existed, rendered dim on
purpose because an available update is not an alarm — but paddock is STARTED in
a terminal, and nothing printed there ever mentioned a new version, so finding
out required already looking at the dashboard for another reason. Announced when
the answer arrives rather than at banner time: the check is async and caches for
a day, so printing only at banner time would have silently dropped whichever
half of that race lost.

And cloudflared's log fought the tunnel display for stdout. startTunnel logged
every line for the life of the child while draw() repainted with ESC[H ESC[J
once a second, so a line landed wherever the cursor was and the next repaint
erased it. Measured with the line that matters most — graceful shutdown due to
signal interrupt — flashing under a block still reading `tunnel up`, because ^C
signals the whole process group and reaches cloudflared before paddock clears
its timer. The lines are held, not silenced: this project does not swallow
errors and cloudflared's stderr is the only place a tunnel failure explains
itself, so the tail is printed on every failing path, where `cloudflared exited
N — the URL is gone` used to be the entire explanation. Only on a tty; a piped
run has no repaint to collide with and its log is the only record there will be.
Teardown now clears the block, because a stale frame asserting the tunnel is up
is worse than no frame at the moment it stops being true.

849 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tale

Two fixes in the section a new reader actually reads.

`paddock tunnel` was documented only under "from your phone", four screens
below the install steps, so the fastest path to the thing paddock is FOR —
seeing it on a phone — was invisible to anyone following install top to bottom.
It now sits with `start`/`status`/`stop`, cross-linked rather than duplicated:
the security posture stays in one place, because a quick tunnel's one gate
being a pairing code is not a caveat to restate loosely.

The herdr-version section still claimed protocol 19, herdr 0.8.0, and that
paddock "refuses to start on a mismatch". All three went stale in v0.7.0, which
made the check directional — a newer herdr is accepted and verified by the
FIELDS paddock reads, and only an older one is refused. Left as it was, the
document told operators that the exact upgrade v0.7.0 exists to permit would
break their install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 20, 2026 12:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Two faults, one symptom: an operator a version behind did not know.

The check fired ONCE, at index.ts:245, and latestKnown was frozen from that
moment. `paddock start` and a dashboard left open on a phone are the two
documented ways to use this thing, and both are exactly the long-lived case that
could never learn about a release published afterwards — the dashboard's notice
and the terminal's both faithfully displayed a value that had no way to change.
A paddock started on Monday reported Monday's view of the world on Friday.

scheduleUpdateChecks now re-asks hourly. The timer is NOT the rate limit:
checkForUpdate's 24h on-disk cache is, unchanged, so GitHub is still contacted at
most once a day and PADDOCK_NO_UPDATE_CHECK=1 still disables the lot. 23 of every
24 ticks are a small local file read. Deliberately not a 24h timer — a tick the
same length as the cache window drifts against it, and landing one millisecond
early means waiting nearly two days. It takes a factory rather than a CheckOpts
for the same class of reason: a captured `now` would make every tick look like
the same instant, so the cache would never appear to expire, which is the
original bug wearing a timer. A rejected check is reported and the schedule
SURVIVES it; a laptop offline for one tick must not stop checking for the rest of
the process's life. Cleared on SIGINT/SIGTERM so a tick cannot land mid-teardown
and print a notice under the shutdown report.

The terminal now names a version discovered mid-run, not only one already in hand
when the banner printed, and says it once per version rather than once per tick.

The notice itself was one dim 10px line in HostHeader, among other dim metadata,
on the recorded reasoning that `paddock update` is "something the operator runs
when they feel like it, not an alarm". The reasoning was right and the placement
was wrong: a line the colour of its neighbours is not read, it is skipped. It is
now ReleaseBanner, and the old line is GONE rather than left beside it, because
two copies of one fact is how they drift.

Dismissable, which no other banner here is, because of an asymmetry unique to
this one: you cannot act on it from the device you are reading it on.
`paddock update` runs on the host, which is by definition not the phone. A notice
you can neither act on nor silence is how a dashboard teaches you to ignore
banners — and the next one is the connection banner, which does mean an agent's
state may be wrong right now. Dismissal is keyed by VERSION, not a boolean:
a boolean would make the first dismissal permanent and the feature would quietly
stop existing.

HostHeader loses `latestKnown` as a prop. Its doc argued the prop was required so
that dropping it would be a type error, which was a good guard — but a required
prop nothing renders is that same trap pointing the other way. The guarantee
moves to App.tsx, the one caller that has the value.

The two HostHeader tests that asserted the dim line moved to the banner rather
than being deleted with it, and app-update-notice.test.tsx keeps its subject —
a value off the wire reaching the screen — with a new destination.

Decision 16. 861 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lntvan166
lntvan166 merged commit 728c6f4 into main Aug 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants