A room server is a message board on a radio: it keeps its members' last
posts and sends each member the ones they missed. MeshTerm could see rooms
and run your own, but not join one. Posts that reached the radio were filed
as direct messages from the room, with their authors thrown away.
Chat now has a Rooms section between Channels and Direct. Opening a room
shows its board at once, each post under the member who wrote it. A post
carries the first four bytes of its author's key, so it is named by any
contact or overheard advert with that key and coloured by the key itself.
An author nothing names stands as the hash, in the unknown-node grey. The
first visit asks for the room's password over the board. After that,
opening a room logs in again only once it has been quiet for
room_relogin_minutes (a new preference, 30 by default), and ^L (F6 on the
handhelds) logs in on demand. The title says what the room allowed:
member, admin or read-only. A read-only member gets a line saying the room
drops their posts, in place of a compose box. A post goes out once, never
soft-retried, since a duplicate would reach every member.
Underneath: messages carry an author (schema 18), a room's board leaves
out its command replies, and a re-sent post is recorded once. Room posts
raise the unread badge and a room's command replies don't. The device
gains room_login, sharing admin_login's exchange and reading the access
the room granted. Room passwords are remembered in rooms.json; an admin
login is kept in admin.json, shared with Repeater admin. The simulator
has a room, Lakeside BBS, to join with "hello".
Two fixes the research turned up. An admin command took the first direct
message from anyone as its reply, so a room post or a companion's message
landing mid-command was read as the node's answer. It now listens before
sending, and only for a reply from that node that is not a post. And
Repeater admin names a room server's guest password "Room password", the
password members join with.
Refs: #24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`meshterm emulate cardputer-zero --mock` and `meshterm emulate picocalc-lyra --mock`
open a desktop window drawing MeshTerm as that device's own screen draws it. An
EmulatedDevice (emulator/devices.py) holds what differs per handheld: the panel, a
Linux console's top-left grid and bold-as-bright 4-bit palette for the PicoCalc, the
lane keys drawn under the Cardputer's panel, and how each keyboard spells Shift with
an F-key (the PicoCalc's F6-F10, the Cardputer's xterm-shifted F4-F8).
It is not a strict emulator, and the docs say so wherever they offer it: MeshTerm runs
on the desktop as itself, and the window mimics the display's and keyboard's
constraints, never the CPU or the OS. Inside it the platform resolves with own_display
(platforms.drawn_by_meshterm), so nothing offers to move to another terminal.
Global options typed anywhere (--mock, --port, --db...) reach the emulated MeshTerm.
The command refuses --json like specimen. The Terminus fetch moved into the package as
`meshterm emulate --fetch-fonts` (with --archive for a copy fetched another way),
replacing scripts/cardputer-zero/fetch-terminus.py. A build without Tk says to install
with pip or pipx instead of failing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
records --width now refuses anything but 1, 2, or 4 with a usage error, the
way --category already refused an unknown discipline. A width that doesn't
exist used to come back as exit 5, indistinguishable from an empty database,
and --width 0 was falsy and so listed every width. (#15)
A device with no port is labelled by its name alone. The simulator fell
through to the serial branch and came out as "… (nothing transmits) ()". (#14)
platform and specimen pass their own one-line help, so Typer stops printing
their docstrings — notes for whoever reads cli.py, Sphinx roles and source
paths included — and their summaries fit in meshterm --help. A new test runs
--help on every registered command and refuses those markers anywhere. (#16)
Built from the issues alone; the pull requests filed against them were
closed unreviewed and their code was never read.
Closes#14, closes#15, closes#16.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The node page of a repeater shows the regions it was last heard to relay
floods for, whether it relays unscoped floods too, and when it said so, and
asks it once on request. The region store keeps that unscoped flag and the
answer's time beside the regions.
Repeater admin gains a Regions page: the region tree read over the remote
CLI (core/region_admin.py parses the dump, drops a line the 160-byte reply
cut in half, and recovers the rest from the flat lists), flood allowed or
denied per region and for the wildcard, home, default scope, add, remove
and save. Each edit is one command applied at once; the title counts what
a reboot would undo. meshterm regions NODE asks from the command line.
The simulator transcribes the firmware's region CLI, reply cap included.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`--mock` is a fake radio, not a fake MeshTerm: what it adverted was recorded to
`meshterm.db` like anything a real companion said, so its four invented contacts
turned up in the mesh walk, the dashboard and the map of the mesh actually being
run. A `--mock` run that names no database now records to
`<config_dir>/meshterm-mock.db`, created on first use.
`--db` still wins — where a run records and which radio it talks to are different
questions, so an explicit path is a precedence rule and not the usage error
`--mock --port` is. `$MESHTERM_HOME` remains the only thing that isolates a run
whole: the contact and channel caches, the outbox and the remembered devices are
untouched by this, and the docs say so where they used to warn that `--mock`
recorded into the real history.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
--mock promises a session with no real hardware, and a scan was the one thing
the devices tool did regardless: it walked the serial ports and switched the
Bluetooth radio on, which broke the promise on the first command a stranger
without a radio would try, and which the test sweep had to work around with
--no-ble. Now the simulator is the inventory under --mock: one row with the
flag itself as its target, since that is how this device is selected, and a
stable id of its own. The sweep's workaround is gone and a contract test
replaces both discovery entry points with ones that fail if reached.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).
Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.
One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.
The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.
apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>