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
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.
Session::open attaches to a daemon when one is reachable and logs in
otherwise, so every existing command works unchanged against either. A local
socket is found automatically, which is what makes an agent's invocation need
no flags at all; --no-daemon opts out.
Commands now read identity, shares, server, and download directory from the
session rather than from this run's config: routed to a daemon those differ,
and the session is the one actually on the network. Without that, whoami
named the wrong server and shares reindex would have imposed the client
machine's folders on the daemon.
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:
- The peer listener bound its port inside a detached thread and panicked on
EADDRINUSE, unwinding that thread alone. The process carried on with no
listener, so no peer could deliver a search response. `Listen::bind` now takes
the socket before login and falls back to an ephemeral port when the
configured one is held.
- SetWaitPort advertised the configured port whether or not it was bound, so
peers dialled a sibling process that discarded responses to tickets it never
issued. The port the listener really holds is what gets advertised, mapped
through UPnP, and reported by `whoami` and `serve`.
- A zero-byte read is EOF on a TCP socket, but MessageReader reported it as
success, so a server that closed cleanly left the actor polling a dead socket.
It is now an error, and Relogged (code 41) has a handler. The peer actor
classifies that error: a control connection that was established and then
closed is a clean disconnect, because peers drop idle sockets while the bytes
move over their own connection, and reporting an error there fails every
download queued with that peer including the one streaming. A connection that
never established keeps the error path, which is what the server-brokered
fallback reads to know a peer is unreachable.
- A session the server cut off - usually because another process logged in under
the same name - exited 4, documented as "it worked and found nothing". Callers
concluded the file was unavailable. Sessions now record why they ended and
those verdicts become exit 7.
The server allows one session per account, so concurrency still needs a distinct
--username per run; SKILL.md documents that. The listener port needs no such
care any more.
Six e2e tests against soulfind cover it, each of which fails without the fix: a
taken listen port, a displaced session, three clients searching at once under
one configured port, the two CLI equivalents, and a peer that hangs up its
control connection while a transfer is still to come.
BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.
Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.
Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.
Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.
Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.
BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
`completions install` generates the script from the same clap definition
the binary parses with, so it cannot drift from `--help`, and writes it
where each shell already looks. fish scans its own completions directory
unprompted; bash and zsh get one marked `source` line appended to their rc
file, because nothing puts a per-user directory on their search path by
default. `completions uninstall` takes back exactly those lines and leaves
the rest of the file alone.
A `cargo install` leaves a binary and nothing else, so there is no file on
disk for a coding agent to read. `skills install` writes an embedded SKILL.md
into every agent found on the machine — Claude Code and opencode today, any
other via --dir — and installing over an older copy is what updating means.
The skill carries what --help cannot: the JSON keys of every record, what each
exit code means, and the rule that an agent always passes --json. A unit test
walks the clap command tree and holds SKILL.md to a documented entry for every
command, so a new subcommand fails the build until it is written up.
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.
New commands:
serve [--follow] stay online sharing; one record per upload
whoami credentials, connection and what we offer
user <NAME> a peer's presence and share counts
room users <ROOM> the member roster
shares list|add|remove|status|reindex
config path|list|get|set
`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.
Library additions, all request/poll pairs matching the existing conventions:
- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
logged as unhandled. The wire format is taken from soulfind's own source
and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns
Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.
The commands are now built for scripts and agents:
search download get browse room list|say|listen message send|read
stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.
Defects found and fixed along the way, each covered by a test:
- a download with an unknown size was finalized against zero bytes and saved
as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
peers shadowed itself and two files sharing a basename overwrote each
other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
server went quiet
Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.
BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.