659 Commits
Author SHA1 Message Date
Jean-Pierre Martineau 8875a45070 MeshTerm 0.3.1
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
v0.3.1
2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau d41111bd5f The missing link refs are cosmetic, and the check now says so 2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau c2fb49a001 Skill: cutting a release, and the two workflows it must never confuse 2026-09-09 02:23:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 398a184eed The splash says bye, and keeps the reader's place when a row goes
Three passes over what landed yesterday.

The hidden-count line stops standing in the list. With devices still
listed the footer already names ⇧H on every row, so the line was a row
of the box spent saying it twice; it stays only for a list hiding has
emptied, where there is no footer atom to read it off and "no
companion devices detected" would be a lie about a machine with a
radio plugged into it.

Hiding now leaves the highlight where the row was - the next device
down, or the one above at the end, never rolling round to the top - so
a run of adapters clears with a run of presses instead of the cursor
jumping back to the remembered default after each one.

And Esc says bye here rather than quit: the splash is the door, and
the reader leaving it has not started anything to quit out of.

One consequence, on the console: the splash border is 47 cells and the
sentence now sheds the hide key too when it must. Between the two
shortcuts the survivor is the way back - ⇧H only appears once
something is hidden, which is exactly when a reader needs to be told
how to undo it, and by then they have found h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 4109c73522 A full pack should not rescale the pulse it sits beside
The charger cuts out at full, the pack settles back to 99, the charger
restarts - and the companion reports that flip every poll for as long
as the thing is plugged in. The gauge is pinned to the header's right
edge and the pulse takes whatever is left, so the fourth cell 100%
needed came off the sparkline and redrew the whole activity history at
a new scale, twice a minute, on a device sitting still.

A full pack reads `100` now, with no sign: three cells, exactly as the
`99%` it keeps flipping back to. It is the one reading whose `%` can
be inferred - a bare 100 beside a full block is a percentage without
being told - and the sign goes on doing its work at every charge that
could be mistaken for something else.

Only that pair is answered, deliberately. The other width change in
the range, 9 to 10, is a boundary a discharging pack crosses once and
never comes back over: one repaint, no jitter, nothing to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 3d3506be4f A route that has finished does not end on the mark for "there is more"
The chevron is the ribbon's continuation cue: it is what a wrapped line
closes with to say the path runs on past the edge, and it is the same
shape as every seam between two hops. So the last chip of a finished
route wearing one read as a route cut off mid-walk. Where the font has
the rounded caps nothing changed - they close the lozenge and always
did. Where it hasn't, the widget already squared the *opening* off and
documented that it squares both; only the close never followed. It now
appends nothing at all and the last chip's own pad is the edge.

The cell that frees up goes back to the route. `_measure` used to
report one closing figure for every line, which was true while the
close was always a point; it reports two now - what a line the path
runs on past closes with, and what the line that *ends* it closes with
- so a wrapped path measures its own last line honestly instead of
holding a cell back for a glyph that is no longer drawn. The one caller
that flows a leg of a route rather than a whole one says so, because
its final line is a continuation and still pays the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 b2bf305a31 Hide the devices on the splash that are never the one you want
A machine with a debug probe, a programmer and a USB adapter soldered
into it shows all three on every start, in front of the one radio the
user actually came for. h drops the highlighted device from the list
and remembers it in the device registry; ⇧H brings every hidden one
back. Both keys are named in the footer only where they would act -
h on a device row, ⇧H only while something is hidden - which is the
rule Del remove already followed.

Hiding is a listing choice and nothing else: a hidden device is still
remembered, still resolvable by --port, and shows itself again the
moment it is connected to. A row that is simply gone would let the
list misrepresent the machine, so a muted line says how many are
hidden and which key returns them - and that line is also where ⇧H
survives on a console whose splash border is 47 cells.

The mechanics are shared rather than local: a non-filterable list can
declare bare-key shortcuts (a filtering one cannot - every letter
there belongs to the query) and name them per highlighted row, and a
chromeless splash whose hint outgrows its box now drops atoms in front
of Esc rather than being cut off at the end, where Esc is.

Two things the narrow console showed while this was being fitted: the
splash header was a hand-built string that wrapped at 53 columns, and
the DEVICE lane sized itself to the longest name - which for a USB
adapter is its forty-character product string, pushing HARDWARE off
the edge entirely. The header goes through column_header, and the name
lane now yields to keep HARDWARE on screen. What is left of a long
model string scrolls: ←→ read the highlighted row's tail to its end,
with the lanes in front of it pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:26:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 3991c723ab Two of the seven disciplines wear a one-cell mark
`🛣` and `🕸` sit outside Emoji_Presentation, so Rich and wcwidth both
measure them at one cell where the other five marks measure two — and
they are right, which is why the width machinery must keep leaving them
alone. But the Trophy case wrote `f"{icon} {title}"`, so those two
headings started their titles a column left of their siblings, and on a
terminal that paints the road wider than it advances the single space
after it was overpainted and the gap looked closed.

The marks now pad out to the widest of the seven, so every heading — and
every row of the delete picker — starts its title in one column. It is
the rule menus.icon_lane already writes down for a command row's icon
column, and deliberately not that function: a command row drops its icon
where the platform draws no icon lane, and a board's mark is part of its
name on both platforms. So the lane measures what theme.glyph will
actually draw here and the marks stay.

Measured rather than written down, which is what makes it survive the
PicoCalc's substitution map still being open: `👣` is the one discipline
mark that has no console-font glyph today and folds to two cells, so the
lane there is two as well. Give it a one-cell substitution later and all
seven headings tighten by a cell with nothing else moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:05:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 18064c44de A rename is not a reason to close the page it renamed
The channel is still there and that is still its page, so the new name
and key are read back into the title, the summary and the rows and the
reader stays put. Only a clear closes it now, which is the one case
where what the page was about is gone. It used to close on both,
because the slot it was holding was a snapshot the rename made stale.

Two other things a slow action left open. The standard Public channel
has one well-known key, so a second copy is a duplicate row rather
than a channel - the menu drops the row once a slot holds it, but the
row dispatches by sentinel, so a press already on its way while the
first write ran added it twice. It is refused where it runs now.

And a reorder is a sequence of writes with no transaction under it, so
a link that drops partway leaves the slots between the two orders. It
says so, at once, because the re-read that follows means the reader is
about to be looking straight at the half-applied layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:53:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 d19e2ec712 Channels goes through the shared row builders it was working around
It was never in the dual-platform gallery, which is why none of this
was caught. Adding it first, then fixing what it showed.

The column header was a hand-built f-string with a five-cell indent
and no idea of the render width: at 53 columns it ran to 54 and
wrapped, costing a content row out of twenty-six and drawing the
pinned landmark twice, and its indent assumed a two-cell channel glyph
where the console draws one. It goes through column_header now, which
measures the glyph and sheds ACTIVITY before it wraps.

The command rows hand-padded their icons, so the one-cell marks (↕ ✎
🗑) started their labels a column left of the two-cell ones - the
Organize row had a two-space hack and a four-line comment about
exactly this. They share a measured icon lane now, and the clear row
tints its mark rather than its words.

The empty message count was ·, which in that same row is also the
status-atom separator and what the console folds 🔕 to: a muted
channel with no messages drew it twice, two cells apart, meaning
different things. It is ○, the app's empty mark.

Also: the detail title was a 42-cell parenthesised blob that filled
the PicoCalc's whole title bar, leaving nowhere to say Esc leaves - it
is "Channel — name", with the openness and hash now leading a
vital-signs line that sheds atoms rather than wrapping. The manager
says "Enter open", which is what its rows do. The slot count chains
with · rather than an em dash. And the four Add rows are replaced by
one empty state when there is no free slot to add into, instead of all
four dead-ending in the same refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:48:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 4c01e1da7e A short list is not a layout, and a free slot is asked before it is used
The slot probe stops for two very different reasons and returned the
same short list either way. Off the end of the configured slots it is
the truth; a read that simply failed leaves whatever it had, and that
was cached for the session - an empty one reading exactly like a
device with no channels, a truncated one making the next free slot
look free with a channel sitting in it, which the add flows then wrote
straight over. The two endings raise different things, so the probe
now says whether it finished, an unfinished one is answered but not
kept, and every add confirms the slot is empty before anything is
written to it.

The chat service's slot map was also walking the radio itself, and
unbounded - it skipped empty slots all the way to the 64-slot cap, so
on firmware that never rejects an index every edit paid 64 round-trips
before the screen could redraw, on top of the manager's own re-read.
It now builds from the probe the manager was making anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:41:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 4ba8da7f03 Channels shows the wait instead of announcing the result
Three things, all the same story - the manager was silent while it
worked and talkative once the work was already visible.

The acknowledgement goes. It reported changes the reader had just
watched land in the list in front of them, after the screen showing
them had closed. The count stays in summary, for the run log.

Every device action now reports while it runs, under the modal busy
card: write_channel draws its own (it is the one boundary every
mutation crosses), a reorder draws one card for the batch and retitles
it per move, and the re-read that follows a change draws one rather
than three. Modal is the point - the list underneath stays armed the
whole time it waits, so keys tapped during a slow write were landing
in its filter and coming back as a list showing nothing.

And "a write happened" moves off the change count onto the cache the
write drops. A write that landed and then raised - a QR view unwound
with ^W, a link lost mid-reorder - never reached changes += 1, so the
stale slot list and the chat service's slot map outlived a layout that
had already moved. write_channel now invalidates as it writes, which
the four CLI subcommands were never doing at all, and the manager
compares the epoch across a round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:32:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a0dc977e6 A busy card that is actually there, and actually takes the keys
The overlay could not report a hub's own slow work: it paints only on
an empty stack, so over a screen kept up with stay() it draws nothing,
and it is not a screen at all, so it takes no keys either. A visited
hub stays armed the whole time it waits, which means every press
during a slow device write still reaches it - letters into its live
filter, Esc resolving a round nobody is waiting for yet, handed back
the instant the work lands.

BusyDialog is BusyScreen drawn as a box over what it interrupts:
modal, so the press dies on the card instead. Nesting keeps the
outermost one, so a batch of writes is one wait rather than a box per
write, and its caption can change to say which step it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:23:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 217625dcf7 The channel manager kept a receipt nobody could read
Every action banked a "created X" note that only surfaced once the
manager closed - by which time the refreshed list had already shown
the result. Worse, the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup's line budget and
came back as the full-frame result window instead, so the same visit
reported itself two different ways depending on how much had been done
in it. The visit now acknowledges once, in the summary the tool
already returned. An error is the exception and is shown when it
happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:50:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 9341c4c1d9 Twenty-two cells reserved for a name most people spell in five
The chat picker's conversation lane was a flat 22 cells, and every cell
of it past the longest name in the list was padding in front of a short
one — taken straight out of the last-message preview, which is the only
run on the row with something to say. On the PicoCalc's 53 columns that
left the message 12 cells: enough for a sender's name and a colon.

It is measured now, against the names the list actually holds, floored
at the header word and capped at the shape of a repeater-style name.
The preview keeps what it saves: 31 cells to 37 on a 72-column
terminal, 12 to 18 on the console.

What the cap costs is the tail of a long name, so the row gained the way
to read past its own edge that every other lane-and-run list in the app
already had: ←→ slide the message under the pinned columns, one row at a
time (Choice.hscroll_from). The preview had to stop pre-truncating
itself at 40 cells to make that worth pressing — a cut made before the
terminal's own leaves nothing to reveal.

The marker lane was measured too, and was wrong. A contact's dot was
padded to "a channel's double-cell glyph", which the channel glyph is on
regular and is not in the console font: on the PicoCalc every channel
row sat a cell to the left of every direct row, header over neither. It
now pads to what the glyph reports.

The footer had to give a cell back for the scroll atom, since it can
coincide with the delete atom on any thread with history — the same
arithmetic the purge preview does in its own comment. Enter opens a
screen here, so its verb is `open` rather than the default `select`,
which pays for most of it; `Del delete history` spends the rest on an
object the highlighted row is already naming.

The picker joins the gallery, which is what would have caught the marker
lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:34:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 4d8c67358c Release 0.3.0
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The command line was rebuilt around the fact that it has two readers.
`--json` is the machine contract and now covers all 49 subcommands
instead of two; the plain face is free to be read by a person, which is
what it was always for.

The changelog entry had been written for the intermediate design — it
promised quoted names and absolute timestamps, both of which the finished
work deliberately reverses — so it describes what shipped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.3.0
2026-09-08 05:07:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 b76a93808f Three more arguments that were reported as the radio's fault
`4` promises a caller that the node answered and a retry is worth
trying. An unknown admin node, an unknown courier contact and an `--at`
that is not a clock time are all decided before a word goes over the
air, so a caller retrying on `4` would retransmit nothing, forever.
`tx-optimize` had already answered the identical question the identical
way for a missing password; these had not caught up.

A refused admin login stays `4`. The node was reached and answered no,
the password may simply have changed, and that is exactly the case a
retry exists for — the line is whether anything was transmitted, not
whether the command worked.

The manual documented the old behaviour and called the inconsistency
out; it now documents the rule instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:03:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 c920f50584 The manual promised awk; now it says which reader it is talking to
docs/cli.md led with "the CLI is meant to be read by awk" and made every rule
follow from it. That promise moved to --json, so the manual now opens on the
split — the plain face for a person at a prompt, the JSON face for a program —
and documents both per command: what each prints, and what shape and fields come
back. Every sample is captured from a real --mock run in a throwaway
MESHTERM_HOME, not written.

Three sections that were missing:

  - Where MeshTerm keeps its state. --db moves the database and nothing else;
    the outbox, the caches, the stored passwords and the remembered device stay
    in the config directory. $MESHTERM_HOME is what actually isolates a run and
    was documented nowhere.
  - About the examples. They come from the simulator, which opens a fresh radio
    per invocation, never fills PKTS, and invents hop names on a device-routed
    trace. And --mock records into the real history unless given its own home.
  - When it goes wrong. The exit scheme was explained and never once shown.

Corrected: quoting is gone from listings, times are ages with --absolute behind
the cli_time_format preference, routes take arrows where paths keep commas,
contacts gained HASH and LOCATION, preferences regained DESCRIPTION, ack and
closing messages now reach stderr rather than being dropped, and the written
pages wrap. Exit 3 now covers a --port that will not open, and the usage class
covers a missing --yes, a bad --sort or --category, an unknown --profile and a
bad courier id. The awk -F'"' recipe split on quotes that no longer exist, so
the recipes go through jq; the cron entry flooded the mesh every 30 minutes,
six times tighter than the cadence picker's own floor, and is now
config advert-cadence with an absolute path for what still belongs in cron.

README: chat is not interactive, trace-path is its own command, and the exit
table is prose plus a link instead of a third copy to keep in sync.

about.md: everything-in-the-TUI-is-in-the-CLI was never true — the map,
dashboard, live feed, watchtower and mesh walk have no command — so it says
nearly, names them, and points at the JSON the claim is now worth making about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:58:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 360c0c131e The keystroke raced the screen that reads it
`_run_live` pushes the picker itself, so the Enter could not be queued
before the call — it was sent blind and lost about one full-suite run in
six, on a machine busy enough to change the timing. `_screen_at` exists
for exactly this and says so in its docstring: wait until the stack is
the depth the flow reached, then press.

Fifteen consecutive runs of the test, and three of the whole suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:30:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 b42b0a473e Take the flag where it is typed, and stop the heading from lying
`meshterm contacts --json` is what a person types, and it failed with a
bare usage error: Click binds an option to whatever command declares it,
so a global on the callback is a parse error one word later. For an
output format that is the wrong way round — `-o json` goes after the verb
in every tool that has one, and --json is the first thing anyone reaches
for. The group's own options are now lifted to the front before Click
parses, so both orders mean the same run. `--` stops the lifting and
--help is never lifted, since `contacts --help` must stay the contacts
help rather than quietly becoming the program's.

And a column headed AGE held `2026-09-08T04:18:25-04:00` under
--absolute. Every other time column already names the fact — HEARD,
RECORDED, FINISHED — which stays true whichever form the run asked for;
only chat's named the form. `when()` now says so where the header is
declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:21:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a71284d7d A bare answer with nothing in it prints nothing, not a dash
repeater-admin on a node that never answered, channels share on an empty slot:
the bare form prints the value, and there is no value. A lone `-` there is a
line of output where the exit status is the whole report — and 5 already means
"ran fine, found nothing", which is the branch a script is waiting on.

The document keeps the shape either way, with null where the answer would have
been, because a consumer branching on it still needs the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:10:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 8d2a7b8281 --absolute asks for another form of time, not for one fewer fact
A never-heard node has no instant to print, so the timestamp language dropped
"never" back to the plain absent token and a contacts listing under --absolute
could no longer tell "we have not heard from it" from "this row has no heard
time". The absent word survives the switch; only the form of a real time changes.

Also drops a dead guard in the preferences writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:07:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 de0f60cbe0 Rewrite the CLI standard for the two faces it now has
The section described a command line "read by awk", which was the premise that
changed: the plain face is read by a person and --json is read by the program.
It stated the quoting, the absolute times and the comma-joined path line as
rules, and all three now say the opposite.

What replaces it is the seam first — a tool states, a renderer draws, the
boundary picks — then each face with the rules an implementer follows and the
reason behind each one, then the exit statuses, with the two classifications
that were wrong somewhere stated out loud: nothing transmitted is not a device
failure, and a rendering flag never moves the status.

docs/cli.md is untouched and now stale in three places: the --json row still
says only two commands honour it, --absolute is undocumented, and every console
sample carries quoted names and ISO timestamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 5215a1b76c Two things the sweep could not see
A block that renders to nothing still counted toward the separator, so
`config restore --dry-run` opened with a blank line it did not have — the plan
is a listing under an outcome that says everything it has to say in the exit
status. The separator now counts what was drawn.

And the device inventory declared `port` and `address` in its rows and never in
its columns, so both fell out of the document: a caller had to parse the target
for a colon to find out which transport it was holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:59:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 e3ae668f4a Give the person at the prompt back what a redirect never wanted
Five small things the seam left standing, each one a rule applied a step too
far or a question asked of the wrong flag.

ack and a tool's closing message go to stderr instead of being dropped. The
reason they were dropped was sound as far as it went — an acknowledgement is not
the answer, and `meshterm contacts > f` must catch only the answer — but stdout
is not the only stream. Someone watching a five-minute `config restore` gets the
setting-by-setting ✓ back, and the redirect still catches nothing extra.

tx-optimize asked --json whether it could prompt. That is a question about
output format, and it answered this one wrong in both directions: with the flag
a piped run failed cleanly, without it a scheduled run on a terminal sat waiting
for a password nobody was going to type. It now asks stdin — and asks it
properly on Windows, where NUL is a character device that isatty() calls a
terminal and getpass then reads the console directly and blocks forever. A
missing password is a usage error, since nothing was transmitted.

courier send on an id nobody has raised a device failure, which told a scheduled
sender to retry a typo. It is a bad argument: the outbox is a local file.

--mock alongside --port/--ble/--tcp used to let --mock win silently, so a
scheduled `--port COM7 --mock info` reported on a simulator while reading as
though it had reached the radio.

And stderr stops folding at 80 columns off a terminal, which is what made
`grep 'not already connected' errors.log` come back empty — the sentence was
there, with a newline through the middle of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:56:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 109fe7645d Move every command onto the seam, and give the plain face its own premise
Forty-nine leaf commands now state a Report instead of printing one, so --json
works on all of them at once rather than on the two that grew a branch for it.
The two hand-rolled JSON paths are gone: devices no longer builds a payload dict
of its own, and its empty scan exits 5 like the plain path always did rather than
0 — a rendering flag has no business moving the report.

The plain face changes premise along with it. It used to be a serialisation
format a person could squint at, and several of its rules existed only to make
splitting safe. Splitting is the machine face's job now, so:

- Names are bare. Alignment is the delimiter; the escaping under the quoting
  stays, because nothing else stops a name a node broadcast from ending its own
  record.
- Times are ages. "Heard recently?" is the question a contacts listing is opened
  to ask, and an ISO instant makes the reader do arithmetic to answer it.
  --absolute puts the instants back; an appointment, the device clock and a live
  capture's own column were never ages and still aren't.
- A route takes arrows and a path keeps its commas. The lexicon has always split
  these and the output never did, so a route line looked pasteable into --path,
  which it is not.
- The listings gain what was cut for parser hygiene: contacts gets the HASH
  --path takes and a LOCATION the model has always carried, preferences gets the
  DESCRIPTION already written in its registry, and the two live streams get
  pinned lanes — which is what let them have columns at all, and with them the
  last call site for the quoting.
- The four written pages wrap at 72 instead of printing 600-cell paragraphs. It
  is the one exception to "no wrapping", and the rule's own reason says why: a
  paragraph has no fields to land under the wrong headings.

info keeps its keys and gains a gloss on the four readings nobody can hold in
their head (93784 (1d 2h)); it is the one key/value block with no set behind it,
so there is no round trip to protect. config show is untouched, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:43:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:16:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 59f10f904c The escape table was a list where it needed to be a category
Turning markup off on the scripted console stopped a node name being read
as a style. It also stopped every tag MeshTerm writes *itself* from being
consumed — so `config export-key > key.hex` wrote a file with
`[muted]` and `[/muted]` around the private key that was supposed to be
its whole content. `PlainUi.note` is documented to take markup and now
resolves it to text, which fixes the leak wherever it is written rather
than only where it was found; the key itself is bare on both faces.

Escaping covered the newline, the tab and the return, and let ESC
through — a live colour run written into the caller's file, which is the
one thing "not a single escape sequence" exists to prevent. BEL was
worse: dropped in silence, so the printed name was not the advertised
one, and U+2028 ended the record like a newline. It is now every C0 and
C1 control plus the two Unicode separators, named escapes where one
reads and numeric everywhere else. Legitimate non-ASCII is untouched.

The covered-command list is now walked out of the Typer app instead of
kept by hand. It was six commands of forty-nine, and both violations
above sat in the forty-three — the `---` in `about` drawn as a rule the
width of a 16384-cell console, and the tags above. Verified by putting
each bug back and watching the sweep name it.

Three assertions could not fail. `len(set(MEANINGS)) == len(MEANINGS)`
is true of every dict, so "no two outcomes share a status" was unenforced
— renumbering NO_DEVICE to 4 left all three green while status 3 vanished
from --help. `" " * script.GUTTER in record` reads the constant it
checks, narrowing to `" " in record` exactly when the gutter narrows.
Both are literals now.

The four rule tests read `result.output`, which is stdout and stderr
mixed, while the rules they enforce are about stdout — so a heading that
only ever appeared on stderr would have satisfied them.

And $MESHTERM_HOME is isolated for every test, not just the CLI ones:
--db moves the history and nothing else, so any test touching a store,
a preference or the logger was reading the developer's own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:52:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 98c635a62e Refuse the wrong answer instead of quietly giving it
Four places where the CLI answered a question the caller had not asked.

`--profile yagi` naming no profile fell through to ordinary discovery, so
a typo in a scheduled advert transmitted from whichever radio happened to
be attached. Naming a profile is a claim about which device; an
unkeepable claim is a bad argument, not a default.

`--sort bogus` sorted by the default and `--category long_hual` selected
every discipline — which then came back as exit 5, the code that means a
real empty result. A caller branching on 5 could not tell "no records"
from "you misspelled the discipline". `ContactsSort.from_name` still
forgives an unknown name, because a saved preference from an older build
must not wedge the menu's list; the command line refuses instead.

A `--port` that would not open reported exit 4 and "the connection to the
device was lost", describing a connection there had never been. Every
failure while acquiring the device is the other status: nothing was
transmitted. It now says which port, and why.

A message body could hold a newline, which ended its record early and
left the remainder indented under the other columns as a second record
with an empty TIME — the one field a stranger fills in, breaking the one
rule the format has. script.oneline folds it flat.

And an unnamed node read as `""` in one listing and `-` in the next:
seven of the ten places that print a name were claiming "called nothing"
where they meant "never said". script.name draws the distinction once,
which is also the one the JSON rendering will need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:31:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in replaced every
empty setting with a pair of quote marks — and the next dump looked
identical, so nothing ever said so. It also masked an *unreported* PIN
behind six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 4a91845651 A manual for the command line, and two rules it caught us breaking
docs/cli.md documents every command and option, what each one prints, and
what it returns -- with the output conventions and the exit-status table
up front, since those are the two things a script is written against.
Every command, option and flag in it was checked against the running app;
the sample output is captured from the simulator rather than typed out.

Writing it turned up two places the CLI still did the thing the last
commit was about. A destructive command refused for want of --yes printed
its refusal in red on *stdout* and exited 1: a colour and a sentence that
is not the command's answer, in whatever was reading the command's answer.
A missing confirmation is a bad invocation, so it now goes through the
parser like any other -- nothing on stdout, the reason on stderr, exit 2.

The README keeps its cookbook and its summary of the conventions, and
points at the manual for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:17:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 da05dd402f Give the CLI its own voice, meant for a pipe rather than an eye
The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.

meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.

The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.

Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.

ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 2790159d04 Ask for a window MeshTerm fits in, rather than taking whatever is open
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
JP saw the PicoCalc's wordmark on a desktop session. The splash picks the
widest mark that fits, the desktop one is 71 columns, and below that it swaps
to the 53 drawn for the handheld -- so the app was announcing itself as the
handheld and truncating every menu description onto the pager underneath.

The window was 64 columns. reopen() passed no window argument, so wt.exe put
the session in a *tab* of whatever window happened to be open and it inherited
that size; with no window open it would instead have taken the reader's global
launch size, which is a setting about their shell and nothing to do with this
app. Either way the app could land somewhere too small for it, and the failure
was quiet and cosmetic rather than anything that announced itself.

It now asks for a window of its own at the platform's stated minimum plus a
margin -- 80x30 on the desktop, derived from readable_cols/readable_rows so it
follows them. Windows Terminal clamps to what the display can show, so a small
screen degrades exactly as it did before.

The tab is titled MeshTerm too. It was showing the executable's full path,
which on a downloaded build is a line of Downloads folder. The icon is not ours
to set -- Windows Terminal takes that from a profile and a bare command line is
not one.

Release 0.2.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.8
2026-09-07 20:28:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 690807370b Take them to the better terminal instead of asking permission to
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The classic Windows console cannot draw a single MeshTerm icon whatever font
it is given, so "reopen in Windows Terminal?" had exactly one sensible answer
-- and was being put to someone who had not seen the app yet and so had nothing
to weigh it against, at the one moment nothing had been drawn.

So it moves. It still says what it is doing and still names the way to stop it,
because a program that opens a window you did not ask for should say so.

Installing a font keeps its consent, and the split is deliberate: moving window
costs nothing and undoes itself by closing, while a font is a file written to
someone's machine that Windows will not let go of until their next logon.

The console_setup preference reads auto/off now rather than ask/off, and its
help says what it does. An older stored "ask" is not "off", so it keeps working
and simply means auto.

JP's call, and it is the right one -- the reference experience should be what
an end user gets, not what they think to ask for.

Release 0.2.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.7
2026-09-07 19:42:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 564e051d1a The reopened session must not inherit the bundle's own bookkeeping
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Found by testing the release artifact rather than the repo. A PyInstaller
one-file build unpacks itself and re-runs itself, the halves coordinating
through _PYI* environment variables. reopen() handed the whole environment to
the new copy, so its bootloader concluded it was stage two of a launch nobody
had made, checked that its parent was the same executable, found wt.exe, and
aborted:

    [PYI-33368:ERROR] Security validation failure: parent process has
    different executable!
    [process exited with code 4294967295]

A new window holding a message nobody can act on, with the window that offered
the move already gone. Strictly worse than not offering.

child_environment() strips them, and restores the loader paths the bootloader
parks aside. Verified against a locally frozen build: before, the bootloader
error; after, the session comes up in Windows Terminal with its emoji and its
charts.

The source build could never have shown this. Same shape as 0.2.2's empty tool
registry -- the packaged build is a platform of its own, and the only way to
test it is to run it.

Release 0.2.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.6
2026-09-07 17:57:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 a11c43c347 Release 0.2.5
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.5
2026-09-07 17:43:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 476294a8db Say whose font this is and which licence it keeps
The OFL permits bundling a font with any software, on conditions we now meet
explicitly. The file is byte-identical to Microsoft's own release (sha256
528f9214...), so it is not a Modified Version and the Reserved Font Name
clause does not apply. Condition 2 wants the copyright notice and the licence
in every copy: CascadiaMono-OFL.txt ships beside the .ttf and travels into the
bundle with the rest of the assets. Condition 5 wants the font under that
licence and no other, which the Apache LICENSE at the root would otherwise
have appeared to claim -- so NOTICE and the README now carve it out by name.

Also says plainly that Microsoft does not endorse MeshTerm, since condition 4
allows acknowledgement and nothing more.

The README's font section predated the Windows Terminal offer and named a
preference that has since been renamed; it now leads with the terminal, as the
app does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:41:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 7be4b8514b A sentence the last commit left in two halves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 54bbb93233 Offer the terminal, then the font -- in that order
The font work made the classic Windows console as good as it can be, and it
still cannot show a single icon. No font fixes that: the only two fonts on a
Windows machine carrying emoji glyphs are Segoe UI Emoji and Segoe UI Symbol,
both proportional, and a console will not take a proportional font. Measured
across every font installed here, no monospace face has more than one of the
33 emoji MeshTerm draws.

What actually decides all of this is the terminal, not the font. A modern one
borrows a missing glyph from another font on the machine, which is why the app
looks right in Windows Terminal with a font (Hack Nerd Font) that holds no
braille and no emoji itself. The classic console is the one host that does not,
so the fix for it is to leave it.

So the first offer is now to reopen in Windows Terminal -- present on every
Windows 11 machine, free on 10 -- asked with yes as the default, since it
installs nothing and changes nothing. The font offer stays as the fallback for
a reader who declines or has no Windows Terminal, and keeps its own second ask.
One preference, console_setup, remembers a refusal of either.

Verified end to end: launched in cmd.exe, accepted, and the session came back
up in Windows Terminal with colour emoji, braille charts and rounded borders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 5158268a73 A font we installed ourselves has to be loaded before it can be selected
Found by testing the second launch rather than the first. AddFontResourceW
adds a font to the running process's font table; the HKCU registration beside
it is what makes it permanent, and Windows reads that at the next logon. So
between an install and a logoff there is a window where a new MeshTerm process
finds its own registration, believes the font is ready, and selects nothing --
SetCurrentConsoleFontEx reports success and quietly keeps the old font.

consolefont.use() retries once after loading our own copy, which is a no-op
for the Cascadia that came with Windows and the fix for the one we put there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:00:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 f06b593a0f Ship a font the charts can be drawn in, and offer to install it
Every timeline in MeshTerm is made of braille characters, and almost no
monospace font has them. Reading the cmap of every font on this machine:
Consolas, Lucida Console, Hack Nerd Font, JetBrains Mono, Fira Code and
Source Code Pro all carry zero of the 256 -- and so does DejaVu Sans *Mono*,
which is where the widespread "install DejaVu for braille" advice falls down.
DejaVu *Sans*, proportional, has all 256. That is the whole trick: terminals
fall back to a proportional face for the block, and the charts have always
been drawn by a font nobody chose.

Which is fine everywhere except the one host that does no fallback. In the
classic Windows console -- what a double-clicked build gets -- Consolas draws
57 of the 122 characters we use and none of the charts.

So MeshTerm now bundles Cascadia Mono PL (SIL OFL, licence beside it) and
offers it there. Cascadia is one of two mainstream coder fonts with the block;
the PL build adds the powerline separators the path lines want, and at 723KB
covers exactly what the 2.4MB Nerd Font build does. A machine that already has
Cascadia -- every Windows 11, anyone with Windows Terminal -- is only offered
the switch.

Both halves are verified on Windows 10 22H2, and neither needs admin: a font
installs for one user under %LOCALAPPDATA% with AddFontResourceW and an HKCU
registration, and SetCurrentConsoleFontEx selects a face the console's own
properties dialog will not even list -- that dialog offers only what is under
the machine-wide Console\TrueTypeFont key, which here is Lucida Console and
Consolas, so without that API the font could be installed and still unpickable.

Declining is answered with what declining looks like -- charts as rows of
boxes -- and asked once more, because a reader who has never seen the app
cannot picture what it costs. A second no is remembered in the console_font
preference and never raised again.

Windows locks a font file once loaded: it cannot be deleted even elevated,
even after restarting the font cache, until the next logon. So nothing here
offers to uninstall one, and the install is written to be repeatable instead
of reversible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:56:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea5f644d73 The console that can't draw an emoji says so before we draw one
The classic Windows console renders through GDI with the console font and no
emoji font behind it, so every icon in the menu came up as a replacement box.
It is what a double-clicked build gets, which is most of the Windows audience.

The fix already existed: the compact single-character glyph table built for the
PicoCalc, reached by Platform.emoji. The seam's flags are independent, so
clearing that one alone keeps the desktop's width, colour and accented names and
moves only the icons — without_emoji() is the whole change to the platform.

Identifying the host has to be structural. It cannot be asked in band, because a
missing glyph still fills its cell: a classic console stores an emoji intact and
advances two cells while showing a box, and a ConPTY reads back as U+FFFD while
drawing it perfectly, so a write-and-read-back probe answers both hosts
backwards. Nor can the environment answer, since WT_SESSION is inherited across
process launches. So two independent facts must agree — the console font has a
real pixel width (a ConPTY's hidden conhost reports zero) and the buffer is
taller than its window (a ConPTY has no scrollback of its own) — and an unknown
never degrades anything. MESHTERM_EMOJI overrides either way, and
`meshterm platform` reports the verdict and its reason.

Verified on Windows 10 22H2 in all three: a real conhost turns the icons
compact, Windows Terminal keeps its emoji, a redirected run is left alone.

_has_wide_glyph asked the same flag to mean "nothing here is two cells wide",
which was true of the PicoCalc's fixed font rather than of the flag. A console
that merely lacks emoji still renders the rest of the BMP, and a contact named
in CJK is two cells wide there like anywhere else, so it now asks ascii_fold —
the fold is what actually guarantees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:47:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 d062b5b706 The Windows console call that broke the app is ours alone
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
MeshTerm died building its screen on Windows with a ctypes type error, and on a
double-clicked build the traceback and the window it printed to were destroyed
together — which is why this needed a log file before it could be looked at.

`ctypes.windll.kernel32` is a cache. Every caller in the process receives the same
object and each function on it is a shared attribute, so declaring `.argtypes` is
not configuration, it is a process-wide mutation where the last writer wins. The
emoji-width probe declared `GetConsoleScreenBufferInfo` as taking a pointer to its
own copy of the screen-buffer struct; prompt_toolkit then called that same function
object with a pointer to its own copy — same layout, different class — and ctypes
refused it.

Nothing here reaches for `ctypes.windll` any more. Five call sites take private
handles from `core/win32dll.py`, built by constructing `WinDLL` directly so each
one gets its own function cache and a declaration made for one purpose cannot be
seen by anything else. Each call returns a fresh handle, because two parts of
MeshTerm disagreeing about a signature is the same bug in miniature.

The regression test was checked against the defect rather than assumed to cover
it: with the old line restored it fails, and with the fix it passes. A test that
passes either way would only have recorded that somebody thought about the
problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.4
2026-09-07 15:16:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 056ac3f770 MeshTerm 0.2.3
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The build to diagnose a double-click with: a window that waits after a failure, a
log that keeps what happened, and a level you can turn up before reproducing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.3
2026-09-07 14:57:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 3379ea2d44 A crash you can read, on a window that waits
Double-clicking the executable on Windows gives the process its own console, and
Windows destroys that console the moment the process ends. So a traceback was
drawn and erased in the same instant: the report was "it displays a trace but the
window closes almost instantly", which is not a rendering problem, it is the
window being owned by the thing that died.

Two changes, because one of them is not enough.

The window now waits. `GetConsoleProcessList` says how many processes share this
console; when the answer is one, nobody else is holding the window open and it
will close with us. On that path only, and only after a failure, MeshTerm asks for
a keypress before it goes. A run started from a shell leaves its output on the
screen anyway, and a run that worked should never make anyone press a key to be
allowed to leave.

And the interactive session logs what killed it. It was catching the missing
Windows console and re-raising everything else untouched, which is precisely
backwards for the case that needs a record most: the session whose console is
about to vanish is the one whose traceback nobody will get to read. It goes into
the file first, and the terminal is told where the file is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:49:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 bdc16fc448 The log is a text file that records what went wrong
Three things were true of the log at once: it was JSON Lines, it never rotated,
and it almost never recorded a failure. Of the last twenty thousand records in a
real one, nine were warnings and three were errors. It faithfully kept the boring
parts and missed the interesting ones, which is exactly backwards for the only
job it has.

It is plain text now, `~/.meshterm/meshterm.log`, one line per record with the
traceback indented under the line that raised it. JSON Lines was there in case
something replayed it; nothing ever did, and the actual reader is a person who has
hit a problem and is about to paste the last twenty lines into an issue. That
reader has a text editor.

It rotates, at 2MB across three files. The old handler grew without limit and a
real one reached 11MB over ten weeks — a lot of disk on a handheld, and an
unreasonable thing to ask someone to attach.

How much it keeps is a preference, `log_level` under a new Diagnostics group,
defaulting to WARNING. That is what makes the file useful rather than large: it
holds the problems, and turning it up to DEBUG for one run is how you chase
something. Loaded before logging is configured, since it decides how logging is
configured, and handed to the context afterwards so the file is not read twice.

And failures are written down at last. Every unhandled error reaches the log with
its traceback and the terminal says where to find it; expected errors — an
ambiguous device, a bad preference — go in as warnings, because a log that only
holds crashes cannot answer "what happened just before". The bug form now asks for
the file, which it had no reason to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:43:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 9b2c5527f8 MeshTerm 0.2.2
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The first build that contains the whole app. 0.2.1's binaries start and then
offer you nothing, so this replaces them rather than adding to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.2.2
2026-09-07 13:54:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 141698678e The downloadable builds actually have the app in them
The Windows build launched, printed help, drew the specimen card, and showed a
menu containing one item: Quit. Not a permissions problem and nothing to do with
Bluetooth — the tools were simply not in the bundle.

`meshterm/tools/__init__.py` finds every tool with `pkgutil.iter_modules` and
imports it so its `@register` decorator fires. Nothing static ever names a tool
module, so PyInstaller had no reason to include one, and the registry came up
empty: no menu entries, no CLI subcommands, an app that starts and does nothing.
`collect_submodules("meshterm.tools")` fixes it, which is the same treatment
`bleak.backends` already had for the same reason — the spec knew about other
people's dynamic imports and not about ours.

The worse half is that CI said this was fine. The smoke test ran `--help` and
`specimen`, and a build with no tools at all passes both. It now runs a
tool-provided subcommand, so an empty registry fails the pipeline instead of
reaching a release page. Green tests that cannot see the defect are worse than no
tests, because they are believed.

`MESHTERM_HOME` comes out of the same session. Every copy of MeshTerm reads and
writes `~/.meshterm`, so trying a downloaded build meant pointing it at the same
34MB history database as the checkout it was built from, with no way to keep them
apart. The variable moves the whole directory. An empty or whitespace value falls
back rather than resolving to the current directory, because a shell exporting it
blank is common and scattering someone's history into their downloads folder is a
poor way to find that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:49:37 -04:00