The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
The results list held the whole shared path in one column, so a search
was read through its folder: every row began with the same peer prefix
and the file name, the part being searched for, sat off the right edge.
Split the column at the last separator, the file name first with the
larger share of the width and the folder dimmed beside it, so names line
up and read at a glance.
The one scroll offset moves both columns together, and $ runs to the end
of whichever overflows further. The website's mock of the pane follows.
Suggested in #55.
Every release since 14 has been a major, because the actor mailboxes,
the share index's struct and the CLI's command enum were all exhaustive
public API: adding a peer message, a scanned field or a subcommand read
as a breaking change to the library. The actors, dispatcher, peer
plumbing and download store are how the client is built, not what a host
programs against, so they move inside the crate; PeerMessage, SharedFile
and the CLI command enums are non-exhaustive; ConnectionType is
re-exported at the root for the one thing hosts used it for. A one-time
break so that the next additions are not.
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
Every search reply we sent carried no bitrate or duration, so our files
failed every bitrate filter in other clients and sorted last in
SoulseekQt, whatever their quality. The share scan now reads each MP3's
first frame header (and Xing block for variable bitrate) and each FLAC's
STREAMINFO, advertising bitrate, duration, VBR, sample rate and bit
depth as far as the format gives them; other files stay bare. Only the
file's head is read, no crate is needed, and a file that does not parse
is shared without attributes rather than skipped.
The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.
One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.
Follows up on #55.
Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on
develop, because every row segmented its whole filename into grapheme
clusters. Walk back from the end for at most one column of cells and
forward for at most the offset, so a row costs the column width rather
than the filename, and drop unicode-truncate for unicode-width. A tail
never starts on a combining mark.
Closes#54.
A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.
Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
Closes#55.
While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.
Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:
- the daemon owned one session for its lifetime with no reconnect at all; and
- the lib's ServerActor queued a Login sent while disconnected instead of
dialing, so even asking it to log in again never re-established a connection.
Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
The e2e job caught this: adding status and file columns to the text record
broke `room_users_lists_who_is_in_the_room`, which asserts the line equals
the username. That test encodes a real contract -- the text output is a
roster to pipe into other commands -- and reformatting it was scope this
change did not need. The statistics stay on the record for `--json`.
Reproduced the failure locally against soulfind, then confirmed the same
test passing with the fix.
Running the e2e suite against a real soulfind showed the per-member slots
field was implemented backwards. The wire field is `slotsfull` -- a boolean
that is true when the member's upload slots are all occupied -- not a count
of free ones. `slots_free: u32` named it, documented it and printed it as
the opposite of what the server means, so a caller reading it would pick the
busiest peer to download from.
- types: `slots_full: bool`, with the wire name in the doc comment
- the DTO, the `room users` output and SKILL.md follow
- a new e2e test joins a room with a member sharing a known three files and
asserts the counts, the roster alignment and the slot flag against
soulfind, so the parsing is checked against a real server rather than only
against frames this repo wrote itself
The statistics parsed from the JoinRoom reply were reachable only from the
library. They answer the question `room users` already exists to ask -- who
is in here, and are they worth downloading from -- so they belong on its
output rather than behind a second call.
- proto: room.member_stats, with a RoomUserStatsDto carrying status, share
counts, free slots and country
- SessionApi::room_member_stats, so local and remote sessions stay
interchangeable; the status word is decoded through one shared helper
rather than a third copy of the match
- `room users` now emits status, average_speed, shared_files,
shared_folders, slots_free and country, each null when the server said
nothing, documented in SKILL.md
- docs/openrpc.json regenerated from the wire types
The watch subscription was reachable only from the library. It is most
useful against a daemon -- that is the session that stays connected long
enough to receive the server's status pushes -- so it needs the wire.
- proto: user.watch / user.unwatch / user.watched, with a Watched response
- SessionApi gains watch_user / unwatch_user / watched_users, so a local and
a remote session are still interchangeable above the seam
- CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md
including the caveat that a one-shot run's watch ends with the run
- docs/openrpc.json regenerated from the wire types
A completed row used to ignore d: only a queued transfer could be pulled
out of the list, so a finished one sat there until the next restart. d
now goes through one handler that drops a finished row whatever the
session thinks of it — a completed transfer can live only in this window,
restored from an earlier run, where there is nothing to forget.
The finished-status test was spelled out twice in clear_finished_downloads
and had to agree by hand; both callers now share is_finished().
In results filter mode, Up/Down move the selection over the filtered
list and Enter confirms the filter (keeps the query, returns to normal
results keys). Footer hints updated.
The peer listener died with "Too many open files (os error 24)" on any
long-lived non-daemon session: only the daemon raised RLIMIT_NOFILE, and
peer sockets were unbounded — no idle reaping, no deadlines on accepted
transfer streams, and one peer could hold two connections under split
registry identities.
- raise the open-file limit at the top of run(), for every entry point,
and warn from Client::connect when the soft limit is below 1024
- reap peer control connections idle for five minutes; a clean
disconnect no longer discards the peer's queued uploads
- give listener-supplied download streams and upload serving the same
read/write deadlines dialled sockets already had
- register inbound peers under their plain username so the registry
holds one connection per peer, and announce them like pierced ones
- hold the registry lock across actor spawn + insert and surface
thread-spawn failure, so an instantly-dead peer cannot leave a
zombie entry
A CLI one-off search leaves its result set in the daemon until someone
forgets it, and an attached TUI made that permanent state expensive:
every 100ms tick asked for the search list twice, and each answer
deep-cloned every result of every search under the context lock just to
sum file counts - stalling the thread that stores incoming results.
Worse, any active or selected search pulled its entire result set over
the socket again each tick, megabytes of JSON repeating what the window
already held.
search.list is now backed by search_file_counts, a walk of the cache
instead of a copy of it; the TUI syncs once per tick and re-fetches a
result set only when the session's file count differs from what it
holds. get_search_results_count reads the same summary, so get --pick
first against a daemon polls a count instead of the full set, and both
session kinds now count files where the local one counted peer
responses.
docs/openrpc.json lived at the repository root, outside the soulseek-rs
package, so the include_str! in proto compiled from a checkout but not
from the published tarball - cargo publish failed verification and the
v13.0.0 release stopped after publishing the lib. The document moves
into the crate's own docs/, where packaging can see it; the generator
test and every link follow.
The serving process built its arguments by hand, so in daemon mode
--shared-dir stayed on the attached run, where shares belong to the
daemon and the flag is ignored. The daemon fell back to sharing its
default folder - which on a developer machine contains the probe files
earlier buggy runs deposited, so the test only passed by finding
served.bin in leftovers. args_with routes the share to the process
whose session does the sharing in either mode.
The feature list said the same things at twice the length, and Project
Goals / Project Structure were two headings for one paragraph.
SKILL.md argued against throwaway accounts on cost grounds. The reason
that holds is standing: the agent arrives under the user's name, peers
judge that name, and inventing a second one throws it away. Exit 7 now
points at starting a daemon rather than at --username.
info.version carried the crate version, so release-plz bumping Cargo.toml
left docs/openrpc.json a version behind and failed document_is_current on
every platform. With the CI gate in front of publishing, that made every
release PR unmergeable. The protocol version is what auth negotiates and
what the contract actually describes.
A connection that has joined a room receives event notifications on the
same socket as its replies, so a notification landing between a request
and its answer was read as the answer. Only replies carry an id.
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).
The bigger cuts:
- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
wrote a peer-init frame down the server socket, and the struct only
they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
UploadFailed, registry and actor getters, build_watch_user,
strip_ansi_codes, delegating shims on ClientContext. Removing public
items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
a migration, border-type helpers that all answered Rounded, the TUI's
max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
navigation blocks into cycle, hit tests into Rect::contains, char
walkers into floor/ceil_char_boundary, five Download literals into
one helper, the suites' shared scaffolding into tests/common.
Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.
Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.
- `download.set_dir`: a session-wide move, published in the contract;
the per-transfer destination stays refused. `daemon.status` reports
it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
live daemon the way `shares add` does, through one shared helper;
the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
pushes both folders over the socket as typed, reports a partial
refusal as exactly that, and creates no client-side directories. It
reads the daemon token from the resolved config — with the token only
in config.toml it could not attach over TCP — and settings
persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
renders it as running rather than instantly finished.
The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
Leading with `get` was the wrong trade. It ranks on free slot, bitrate and peer
speed, which are facts about the transfer and say nothing about whether the
file is the track that was asked for. On a setlist of remixes, dubs and white
labels it does not miss, it succeeds with the wrong one: exit 0, a file on
disk, and nobody finds out until it plays. A miss can be retried; a confident
wrong answer cannot. The previous revision optimised the measurable cost and
paid for it in the unmeasurable one.
The token problem it was solving is real, but the answer is to narrow before
reading rather than not to look. A search writes its records to a file; search
flags and jq cut three hundred down to eight without any of them entering
context; the eight are read and judged. `get` keeps a scoped mention for when
the user has named nothing in particular.
Adds what "the right one" means, since that is now the load-bearing step: fold
both sides, require every distinctive word, reject live/radio edit/snippet/dj
mix, prefer lossless, and above all treat the version as the thing you match on
rather than the thing you dropped. The ladder drops "(Kalabrese Remix)" to find
anything at all, so the match must then require "kalabrese" or it takes the
original and reports success.
Checked against agents given only this file on a remix-heavy setlist: 2/2
refuse `get`, narrow before reading, require the remixer's name in the chosen
path, and report a track as unavailable rather than silently taking the
original when only that exists.
Feedback from a real 32-track run: 70 agents, 2.48M tokens, 18.7 minutes, most
of it avoidable. Four things, three adopted as reported and one corrected.
The skill mandated a handoff it gave no mechanism for. Subagents had to report
a pick, while the rules said remote paths are backslash-separated, may contain
anything, and must be passed back byte-identical. A path does not survive a
subagent's return, a coordinator's JSON and a child's argv, so every agent
re-derived a workaround from prose. The idiom is now written down: append every
rung to one ndjson file, select the pick by line number, feed it to `download
--stdin`. The bytes never leave the file, and a subagent reports a file and a
line rather than a path.
`get` is now the default and subagents the exception. It searches, ranks and
downloads in one command, so results never enter any context, which is where
the tokens went. The section says when to escalate: a named mix, an ambiguous
title, four dead rungs, or several runs landing on the same peer.
Browsing triggers on any artist appearing more than once, not only on a whole
album. In the reported set that was 14 of 32 tracks across six artists.
The one correction: "rung 2 empty means rungs 3 and 4 are ceremony" is not
safe, because rung 3 folds diacritics and punctuation and that is often exactly
why rung 2 found nothing. The general rules are that a rung producing the same
string as the last is not a rung, and that no results at all means the tokens
are wrong rather than too many, so jump to the widest rung instead of stepping.
Streaming picks into transfers is untouched; the report singled it out as
working.
Checked against agents given only this file on the same shape of set: both lead
with `get`, spawn 3 and 6 subagents rather than 32, keep result sets out of
context, browse the repeated artists, and hand paths over as a file and a line
number.
The clearance step read as a barrier. "Only you can see every pick" invited
collecting all of them before releasing any, so a forty track set moved no
bytes until the last search finished, and one white label nobody is sharing
held up thirty-nine tracks that people are.
Nothing about the safety needed a barrier. Answering a single pick takes two
running counts, transfers to that peer and transfers overall, so picks are now
cleared as they arrive and the first file transfers while the last track is
still on its second rung. Says so explicitly, because the previous wording did
not have to be read as batching but was.
Also points a track that survives all four rungs at the wishlist rather than
just reporting it missing: the server re-runs those on its own schedule, which
is the right home for something nobody is sharing right now.
Checked against agents given only this file, on a forty track set with
unfindable tracks in it: 2/2 start transferring before the searches finish, and
both say a missing track holds up nothing but its own subagent.
Twenty transfers, twenty subagents, ten searches on the wire. The transfer
number is not invented: it is the client's own default, and the reasoning is
already in config.rs, that a Soulseek transfer is paced by the peer sending it
so a fast link is filled by many transfers rather than faster ones. Twenty
costs twenty sockets.
Two numbers deliberately did not scale with the rest, and the table now says
why:
- Three from one peer, up from two. This is the one that gets you banned. A
client serves ten upload slots by default, so three is a visible minority of
one person's capacity rather than a monopoly on it. Twenty transfers is fine;
twenty transfers from two people is not.
- Ten searches, not twenty. The server rate-limits searches and it is the
daemon's single connection making all of them. A subagent spends most of its
life waiting on a transfer, so twenty subagents behind ten search slots costs
nothing.
Four rungs is unchanged and now says it is not a concurrency limit at all.
Checked against agents given only this file, including a batch where one peer
has every track: both read the raised numbers correctly and both hold at three
from that peer, letting their queue feed the rest rather than opening
twenty-five transfers on one machine.
Soulseek matches a query against the filenames peers happen to have, and the
name a user gives is usually more specific than any of them: a mix suffix that
is not in the file, a diacritic the uploader typed as ASCII, an ampersand where
the file has a space. Searched verbatim that is exit 4 for a track dozens of
people share.
Adds a four-rung ladder (as written, without the version suffix, folded to
ASCII without featured artists, then the rarest two or three words), stopping
at the first rung that returns anything, and then matching results back against
what was actually asked for, since widening finds candidates but does not
choose. Reconciled with the rate-limit rule: a wider query is a different
query, repeating one is not.
Also fixes an inconsistency the concurrency numbers had grown: the subagent
route capped transfers per peer but never overall, while the batch route said
-c 5, so the same skill gave two answers. There is now one table covering
subagents, searches on the wire, transfers, transfers per peer, and rungs per
track. Five searches against ten subagents is deliberate, because the server
rate-limits searches harder than peers rate-limit uploads.
Checked against agents given only this file: 2/2 climb the ladder rather than
reporting failure on the first empty search, stop at four rungs, and verify the
result is the track that was asked for.
The batch guidance optimised for parallelism and judgement and was nearly
silent on what actually gets you blocked on Soulseek, which is the thing an
agent driving this unattended is most likely to walk into.
Rewritten around the risk, in the order it bites:
- Sharing nothing is the commonest reason to be banned, so `shares status` is
now a precondition: if files is 0, say so before downloading anything.
- Queueing many files at once from one person reads as abuse to them. Subagents
now report their pick and wait, because only the main agent can see every
pick and resolve two subagents landing on the same peer. The batch form gets
the matching rule: interleave the lines so consecutive ones name different
users, since the workers take them in order.
- Searches are rate-limited by the server, so never re-run a query; pick again
from the results already held.
- A failed transfer means take the next candidate, not retry the same peer.
Also adds the strategy for an album, which fresh agents reading the previous
draft worked out for themselves and which is both faster and safer: search
once, browse whoever has most of it, and only search per-track for the
remainder.
Checked against agents given only this file: 2/2 verify shares first,
coordinate peers, and refuse both the retry loop and the repeat search.
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.
The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.
Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.
Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.
A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
A search fans out to hundreds of peers, each holding a socket. A shell hands
its children a soft limit of 256 open files on macOS, which one busy search
blows straight through; a one-shot command never noticed because it exited
before it mattered, but a resident daemon hits it and then cannot accept peers
or control connections. From a client that looks like 'cannot reach the
daemon: Connection closed', intermittently, for no visible reason.
The daemon now raises its own soft limit to 8192 at startup, before anything
opens a socket, and says so plainly when the hard limit will not allow it.
macOS rejects an unlimited hard limit for open files, so both fields are named.
Both accept loops also slept zero on failure. Out of descriptors does not clear
by the next instruction, so one exhausted moment became a spinning core and
thousands of identical log lines — which is what the report looked like. They
now back off.
spawn_connection used thread::spawn, which panics when a thread cannot be
created. That panic unwinds through the accept loop, so on a client already
running a thread per peer one exhausted moment would end control for the life
of the process — every later connection accepted and immediately closed, which
is exactly what 'cannot reach the daemon: Connection closed' looks like from
the other side.
Builder::spawn instead: refusing the one connection, with a reason in the log.
Starting the daemon printed 'ready' and nothing else, so the token and the
next step were both somewhere else. It now prints what other commands can do
from here, and when --bind is given, the token itself with the two config
commands a remote client needs. All on stderr, so stdout stays empty and
daemon still composes in a pipeline.
Adds the guarantee daemon mode is for as a test: eight clients connected at
once are each answered correctly, none has its replies crossed with another's,
and the daemon still counts all eight. Plus 250 connect-and-drop cycles, well
past the connection cap, to catch a per-connection resource that is not
released — the shape a daemon that works for a while and then refuses
everybody would take.
A connection the daemon drops before it can be served now logs why at warning
level. A client only ever sees 'the connection closed', so that line is the
only account of the reason.
A remote daemon could only be named with --daemon and --daemon-token on every
invocation, which made the one workflow daemon mode is most useful for — a
headless download box driven from a laptop — a matter of retyping a 64-character
token. Both are now config keys, so it is two 'config set' commands once and
plain commands thereafter.
Fixes a bug that setup exposed: the up-front daemon status probe read the token
from the command line only, so with the token in the config file it failed
silently and every command that names a path or a server fell back to this
machine's defaults — whoami claiming downloads land in the laptop's home
directory when they land on the box. The regression test was checked by
reintroducing the bug.
Documents the download-box workflow in plain terms across the README, the
website and the agent skill, and says plainly that the schema is open: OpenRPC,
served by the daemon itself, so an Android, iOS or web remote is something
anyone can build without asking.
The skill buried daemon mode two-thirds down as a scaling trick, and still
offered --username "$SOULSEEK_USERNAME-$RANDOM" as the way to run commands in
parallel. That is worse than underselling it: the server allows one session
per account, so a per-run name means registering a throwaway account on a
public server for every search — account churn on infrastructure other people
maintain and share.
Starting a daemon is now the first instruction in the file, with the reason
stated, and the random-username idiom is named as the anti-pattern rather than
offered as an alternative. Exit 7's advice changes accordingly: it means
something else logged in as you, and the fix is a daemon, not a new name.
Three agents given only this file and a parallel-work task all started a
daemon and none invented a username.
The startup snippet also had a shell precedence bug: in
`daemon status || daemon &`, the & backgrounds the whole list, so it returned
instantly and the next command raced the login it was meant to use. Replaced
with a bounded wait, checked against a live server.