Skip to content

Wave CLI - #86

Merged
david-sling merged 29 commits into
mainfrom
wave-cli
Sep 28, 2026
Merged

david-sling merged 29 commits into
mainfrom
wave-cli

Conversation

@david-sling

Copy link
Copy Markdown
Owner

Closes #75, closes #76, closes #77, closes #78, closes #79, closes #80, closes #81, closes #82, closes #83, closes #84.

wave: a client of the v1 API and nothing else. No endpoint changed for standard channels. Published as @david-sling/wave.

The driver is permission grants, not ergonomics. The M0 spike left eleven grants in one .claude/settings.local.json against a single host, each the full command with the bearer token, the after= cursor and the message body inside it — the parts that change per call, sitting in the middle of the command, where no prefix rule can reach them. wave inverts the shape: the command name is the whole constant part and everything varying is a suffix.

The CLI holds no state

No session file, no cursor file, no config, no ~/.wave. Every invocation is its arguments and one HTTP call. join prints an opaque session string carrying host, channel, participant and token; every later command takes it back. A test scans all of src/ and fails on an import of node:fs, because the property is invisible in any one file and is the one the design rests on.

The earlier file-backed draft handled worst the case that matters most: two agents in the same channel on one machine, sharing one file, the second join overwriting the first one's token. The integration test has two agents joining one channel and coming away with two sessions.

The cursor travels in the output. wait and tail end with -- next: --after N, after the items and in the same stream, so an agent that did not receive the items did not receive the advance either. On a timeout the line still prints, unchanged, so there is always exactly one line to carry forward. A message whose own text spells a cursor line cannot forge one: the real cursor is generated, not copied, and is the line after it.

The commands

join takes host, channel and invite out of one URL, and refuses mode disagreement in both directions — plaintext into an encrypted room and ciphertext into a plain one are each worse than failing. send reads stdin on -, which is how a diff goes in without a shell rewriting it, and gives the secret filter exit 6 of its own, because an agent that read a 422 as a transport error would retry it forever. wait reissues held polls internally — one tool call per wait rather than one per poll — skipping own items in the output and never in the cursor. tail, who, leave.

Failures map to one exit code in one place: 410 and 401 both mean gone, since a participant token is never reissued.

Two drift tests, both in the app

cli/src/types.ts is a copy, not an import. tests/cli-types.test.ts holds it against the zod schemas and the response types — it lives here because this is where a schema changes, and in cli/ it would fail on the next CLI change instead. tests/cli.test.ts drives the CLI through the real route handlers with no server and no network, since the CLI's fetch is an argument and a route handler is a function from a Request to a Response.

The prompt

A second template with its own drift test against PRODUCT section 7, offered as a toggle. curl stays the default until the CLI has been through the validation section 16 gave the curl prompt.

The session string is written to a file the agent owns and read into WAVE_SESSION, not pasted into each command. The draft in ARCHITECTURE section 11 spelled it --session "$S", which cannot be true beside the curl prompt's own header — a fresh shell keeps nothing in a variable — and a literal string would put a token inside the permission grant, which is the defect the whole client exists to avoid. That reasoning is now in section 11, not only in the issue.

Verified

npm test (557 passing in the app, 93 in the CLI), lint, typecheck, production build, and the integration suite against real Redis. The CLI suite runs on Node 20 and 24 in CI.

The guard was run on four real runtimes, including this machine's default Node 16: 16 and 18 name the version they found and exit 1, 20 and 24 reach the dispatcher.

Driven end to end against a live instance three times: the local build, the packed tarball, and the published registry copy — two agents in one channel, a two-line message on stdin, a real held poll timing out at exit 2 still printing its cursor, and a left session answering exit 5 from every command. The CLI prompt was executed verbatim, which is where step 2 gained a clause: exit 2 on an empty room means only that nobody has spoken, and an agent could read it as failure.

The toggle was checked in a browser at desktop and 375px, which is where the two mounted prompt boxes turned out to share one radio group name. Fixed with useId, with a test.

Not in this

npx (measured, and the registry round trip per call is the objection), replacing curl, the MCP server, e2ee, and any state in the CLI.

After merging

  1. Configure the trusted publisher on npm — it needs a package that exists, which is why 0.1.0 went up by hand and carries no provenance.
  2. Bump to 0.1.1 and tag cli-v0.1.1: the first release through the workflow, and the first with provenance.
  3. The gate — an operator-observed run across the agent products in PRODUCT section 11, counting dialogs from outside the agent — is still owed before the CLI prompt becomes the default. It wants an issue of its own.

david-sling and others added 13 commits September 17, 2026 00:29
The guard being tested is the one that refuses to join on a directory that
already holds a live token, so each case writes a token-shaped file into
the system temp directory and spawns a shell against it. None of them were
ever removed: four per run, kept forever. This machine had 128 of them,
which is thirty-two runs.

Nothing in them is secret — the token is a literal in the test — but they
are precisely the thing step 6 of the prompt tells an agent to clear on the
way out, and the test exercising that instruction was the one ignoring it.

Removed in a finally, so an assertion failing mid-case still cleans up.
Verified by counting the directory before and after: stable across repeated
runs with the fix, and four per run without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #76, refs #75.

`cli/` is a package of its own with its own lockfile, its own tsconfig, its
own vitest config and no runtime dependencies at all. Deliberately not a
workspace: the root install and the root build are what Vercel runs, and
they never see this tree. CI grows a second job instead, on Node 20 and 24
— the floor the binary enforces and the version the app job already uses.

The version check is code rather than `engines`, and it is the first thing
the package executes. `engines` only warns; npm installs anyway. Node 16
has no global `fetch`, so a CLI trusting `engines` installs cleanly and
then dies at its first request with `fetch is not defined`, which reaches
an agent as a Wave outage rather than as a Node version, and reaches its
human as a bug report against the wrong project.

That makes `bin/wave.cjs` the one file here that is ES5 in CommonJS: it has
to parse and run on the Node that is the problem in order to say so, which
an ES module with a top-level import would not. Everything past the check
is handed to `dist/index.js` by dynamic import, so no modern syntax is
reached until the runtime can take it.

Not hypothetical, and not a guess. This machine's default `node` is 16.20.2
with 18, 20, 22, 24 and 25 beside it under nvm, which is the ordinary state
of a developer's laptop. The binary was run on four of them: 16 and 18 print
the version they found and the version they need and exit 1, 20 and 24 reach
the dispatcher. The tests fake `process.versions.node` instead, so they
assert the same thing on a machine that has only one runtime.

`src/index.ts` is the whole program as a function of its arguments, and
`src/commands.ts` is an empty registry the six verbs land in. Nothing
user-facing ships yet; the point is that the shape is settled before six
commands are written against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #77, refs #75.

The two halves of the stateless contract, as pure functions: no filesystem,
no network, so every command inherits the same rules rather than writing
its own.

`session.ts` encodes host, channel, participant and token — and the e2ee
key when there is one — as `wv1.<base64url>.<checksum>`. One shell-safe
word, because an agent composes it into a command line, so an id carrying a
space would split the argument rather than fail and surface as a 401
somewhere else entirely. Decoding is strict on purpose: a string cut short,
re-spelled, or missing a field is an error naming what is wrong, never a
half-populated session that produces a confusing 401 three calls later. The
checksum is not a signature and the comment says so; what it buys is
"damaged or cut short" instead of a JSON parse error. Every prefix of a
real string is tested, one length at a time, because a scrollback cut and a
wrapped line are how this will actually arrive.

`render.ts` prints items in the shape the join prompt's jq line produces,
so a transcript reads the same whichever path an agent took, and then ends
the round with the cursor for the next call. That is the rule of PRODUCT
section 7 made structural: the curl prompt spends three sentences telling
an agent to advance only after reading, and here an agent that did not
receive the items did not receive the advance either. The line prints on an
empty timeout too, cursor unchanged, so there is always exactly one line to
carry forward.

The cursor line is generated, never copied from an item, and there is a
test on a message whose own text spells `-- next: --after 99999`. Another
participant's words reach this output verbatim; the real cursor is the line
after them.

`types.ts` is the copy of the API's shapes the CLI reads. The test that it
still matches `lib/types.ts` is its own task and lands in the app.

One more test covers the whole of `src/`: nothing imports `node:fs`.
That property is invisible in any single file and is the one the design
rests on, so it is asserted rather than reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #78, refs #75.

`wave join <channel-url> --name <name> [--client <product>]` takes host,
channel id and invite out of the single link an agent was given, rather than
three arguments that are three chances to pair the wrong invite with the
right channel. A link with nothing after the `#` is refused by name: the
fragment is the capability, a link copied out of an address bar that lost it
looks complete, and the alternative is a 401 that says nothing about why.

It prints the roster, then the session string, then the cursor. The cursor
is last here exactly as it is after every wait, so there is one line to
carry forward whichever command produced it.

Mode disagreement stops the run in both directions — a key with a
`standard` channel, or an `e2ee` channel with no key — because plaintext in
an encrypted room and ciphertext in a plain one are each worse than failing.
The join has already minted a participant by then, so the refusal prints the
`wave leave` that undoes it rather than leaving a ghost in the roster.

`--client` is taken from the flag, else from the one environment mark that
is actually known (`CLAUDECODE`), else omitted. It stays self-reported and
unverified whichever way it was filled, and the table does not grow by
guessing.

This is also where the shape the other five commands land in arrives: `Io`
(stdout, stderr, stdin, fetch, env, sleep) passed to every command, so a
test drives a whole run with no terminal and no network; `args.ts`, strict
enough that a mistyped `--seesion` is an error rather than an
unauthenticated request; `client.ts` over the v1 API with nothing new in it;
and one place in `index.ts` where a failure becomes an exit code, so every
command means the same thing by one. 401 joins 410 at exit 5 there: a
participant token is never reissued, so a refused one is a session that will
not work again, and an agent reading that as a transport error would retry
it forever.

`tests/cli.test.ts` drives the CLI through the app's real route handlers
with no server and no network — the CLI's `fetch` is an argument, and a
route handler is a function from a Request to a Response. It lives in the
app because `cli/` must not depend on it, and it is the only place the two
can be caught disagreeing about a shape. Two agents joining one channel
come away with two sessions, which is the whole reason there are no files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #79, refs #75.

`wave send --session <s> <text> [--done] [--reply-to <seq>]`, and `-` reads
the message from stdin. That is not a convenience: the curl prompt writes
the message to a file and pipes it through `jq -Rs` for exactly this reason,
and a client that only took an argument would have solved nothing for the
stack trace and the diff, which are the two things most worth sending.

Trailing whitespace is trimmed and leading whitespace is not — a heredoc
ends in a newline, and the indentation of what was pasted is part of it.
Empty after that is refused rather than posted, separately for stdin and for
an argument, because "nothing arrived on stdin" and "you passed an empty
string" are different mistakes.

A fresh random `client_id` per invocation, generated here and never shown,
so an agent cannot reuse one by accident. Its job is the one retry: a 5xx or
a dropped socket is tried again under the same id, so a response lost in
transit cannot land the message twice. A 4xx is the instance's answer and is
not retried.

The secret filter gets exit 6 of its own. A 422 from it is not a failure of
the command, it is the channel refusing that text, and an agent that read it
as a transport error would retry it forever. The API's own message and hint
are printed; the CLI adds no rule and no second opinion.

What it prints on success says what the seq is not: a write position, not a
cursor. That is `e757a17` made structural — an agent that carried a post's
seq forward as its cursor skipped every message posted while its own was in
flight, including one addressed to it by name.

The integration test sends through the real routes and is refused by the
real secret filter, so exit 6 is tied to the filter the API actually has
rather than to a stub of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #80, refs #75.

`wave wait` reissues 50-second polls internally until something arrives from
someone else, prints it, and stops. One agent tool call covers a fifteen
minute wait, where curl needs one per poll — which is most of what this
package is for. `wave tail` is the same loop without the deadline, for a
person at a terminal or an agent reading a stream, and it has no --timeout
to give it, because a tail that stopped on one would be a wait with a worse
name.

Own items are skipped in the output and never in the cursor: the session
string carries this participant's id, so the rule the curl prompt has to
teach is just how the code behaves. Skipping them in the cursor too would
mean re-reading them forever. System items pass through, so an agent still
sees its own arrival announced — they carry a subject rather than an author,
exactly as the prompt's jq line leaves them.

A timeout still prints its cursor line, so there is always exactly one line
to carry forward and no branch where an agent has to decide what its cursor
is now. `--timeout 0` is one immediate read of whatever is already there,
which is the prompt's "read the room before you speak" with no new rule.

Retries: a 5xx or a dropped socket backs off 1s, 2s, 4s to a 60s cap and
resets on the first good poll; a 429 waits exactly as long as `Retry-After`
says, because the instance knows how much of its own limit is left and this
is the one place a client can be exactly right; a 410 or a 401 stops at once
at exit 5, since no amount of waiting fixes a channel that is gone; a 400 is
this client asking wrongly and is not retried at all.

Nothing handles a signal. A run cut short prints no cursor line because the
cursor is the last thing written, so the caller keeps the --after it already
had and sees the same items again rather than losing them. The test asserts
the premise rather than the signal: at no point during a run is there half
an answer on stdout.

The clock is injected, so the test suite runs a fifteen-minute wait, seven
backoffs and a 429 in no time at all, against the same code that would
really hold. The integration test drives the real route handlers with
`--timeout 0`, where one agent hears another and neither hears itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #81, refs #75.

`wave leave` posts the leave and exits. There is nothing to clean up,
because the CLI holds no state — the curl prompt's step 6 ends with
`rm -rf "$W"` for the token sitting in plaintext on disk, and there is no
such directory here. The session string simply stops working, which is the
same answer an expired channel gives, and the integration test walks every
command over a left session to show it is one story rather than four.

`wave who` prints the roster with presence and the client each participant
reported, exactly as the roster carries it. Folding `claude-opus-5` onto a
known product is the server's business; doing it here as well is how the two
come to disagree. A participant that reported no client is one part shorter,
not a line with a gap in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #82, refs #75.

`cli/src/types.ts` is a copy rather than an import, because the CLI is a
separate package and must not pull the app's tree into its install. A copy
nothing checks is a copy that drifts, and this drift surfaces as an agent
reading a field the server stopped sending.

The test lives here rather than in `cli/` for the same reason
`lib/join-prompt.test.ts` and `lib/agent-docs.test.ts` live here: the app is
where a schema changes, so the app is where the failure belongs. In the CLI
it would fail on the next CLI change instead, months after the one that
broke it.

The item shapes are read out of the zod schemas at runtime, since they are
values; the response shapes and the error codes are read out of the app's
TypeScript with the compiler's own parser, since they are types. Both sides
end up as field name, optionality and a rendered type, which catches a field
added, removed, renamed or retyped, and an enum gaining or losing a member.
Named types compare as their names, and an inline object literal compares by
its member names — anything deeper has a name of its own.

Each of those was checked by making the change and watching it fail, in both
directions: a field added to `authorSchema` here, and a field dropped,
retyped, or made required in the copy there. The messages name the field and
both files, so the fix does not require reading the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #83, refs #75.

`cli-v*` and nothing else: the app ships on its own clock, and a shared tag
namespace would publish this package every time the app was released.

npm trusted publishing over OIDC, so there is no npm token in repository
secrets to leak, rotate or scope wrongly — the job proves it is this
repository at this tag and npm issues a short-lived credential in exchange.
Provenance is on, which matters more than usual for a package whose whole
job is to hold someone's token: an installer can see which commit produced
the tarball.

Two refusals, both before anything is built, because a published version
cannot be replaced: the tag and `package.json` must agree, and the version
must not already be on npm.

The first-publish steps are in `cli/RELEASING.md` rather than in this
message, because that is where the person doing the release will look. The
scope has to exist, the trusted publisher has to be configured against this
workflow filename, and the first publish needs `--access public` since a
scoped package is private by default. No workflow file can do any of that
for itself, and skipping it fails the release rather than the setup.

`cli/README.md` is the npm page: the six commands, the exit codes, and the
one thing an agent has to keep. LICENSE is copied in so the tarball carries
it — npm only takes one from the package directory.

Verified as far as it can be without publishing: `npm pack` ships `bin/`,
`dist/`, README and LICENSE and nothing else, 18 files and no dependencies;
the packed tarball installs and its `wave` runs; and the installed binary
was driven against a local instance over real HTTP — two agents joined one
channel, one sent a two-line message on stdin, the other's wait printed it
with the cursor, a six-second held poll timed out at exit 2 still printing
its cursor, and a left session answered exit 5 from every command. The
workflow itself is unrun until the first tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #84, refs #75.

A second template in `lib/join-prompt.ts`, held against a block in PRODUCT
section 7 by a test — the same treatment the curl template has, and for the
same reason: the browser builds the prompt, because the invite lives in the
URL fragment and never reaches the server, so the text is duplicated into
the module and something has to hold the two together.

**Where the session string lives, settled.** The draft in ARCHITECTURE
section 11 spelled every step `wave send --session "$S" ...`, which cannot
be true beside the curl prompt's own header: a fresh shell keeps nothing in
a variable. The alternative it implied — a literal string pasted into each
command — puts a participant token inside the command and therefore inside
the permission grant the agent's tool records. That is exactly the defect
that cost the curl path eleven grants against one host, and most of the
argument for having a client at all; a CLI that reproduced it would have
kept the ergonomics and thrown away the reason.

So the prompt writes the string to a file the agent owns, keyed on NAME the
way `$W` already is, and reads it into `WAVE_SESSION` in a four-line
preamble pasted at the top of every command. Every command is then `wave
send`, `wave wait`, `wave who` with nothing varying in front. That file is
the agent's and not the CLI's — nothing in the package reads it or knows its
path — which is the distinction the stateless design rests on, and it is now
written in ARCHITECTURE section 11 rather than only in the issue.

The cursor stays out of every file: it arrives on the last line of each
wait, it is a small number and not a secret, and a file holding it is the
shared-cursor bug the curl prompt already guards against.

The prompt was run as written against a live instance rather than reviewed:
step 1's guard refused a second join under the same NAME, step 2 read an
empty room and returned exit 2, and a second agent joined the same channel
on the same machine and the two held separate sessions throughout — the case
all of this exists for. Step 2 gained one clause from that run, because exit
2 there means only that nobody has spoken and an agent could read it as a
failure.

The toggle is a segmented control above the preview, curl selected, with one
line saying what each choice costs. An encrypted channel is offered the CLI
alone and no toggle, since the key belongs in a process rather than in a
shell. Both prompt boxes are mounted at once — the empty channel's and the
dialog's — and radios outside a form share one group per name, so the group
name comes from `useId`. That was found by looking at the rendered page, not
by reading the code, and there is now a test on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #75.

M4 stays a section of this document rather than a milestone in the tracker,
which is how the last two features shipped: a feature issue with task issues
under it. E2EE and the MCP server are still docs only.

The README is deliberately untouched. It says there is nothing to install,
which is still true — curl remains the default and the CLI is a toggle — and
the package is not on npm until the first `cli-v*` tag. Pointing readers at
an install that does not resolve yet would be worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #83, refs #75.

A trusted publisher is configured on a package's settings page, and a
package that has never been published has no settings page. npm's own docs
do not mention it; the chicken-and-egg is npm/cli#8544. So the first version
goes up from a laptop and every version after it comes from a tag, and the
release doc says so rather than leaving someone to find it at the moment
they are trying to ship.

Two things were wrong here and are now right. The `@david-sling` scope needs
no creating: it is a username's own scope, so there is no organisation step
and nothing else can take a name inside it. And the first publish carries no
provenance, because provenance is generated by the OIDC exchange that cannot
run yet — so the doc says to tag the next version straight after, leaving the
version people actually install traceable to a commit.

Added while there: set the package to require trusted publishing once it
exists. That is what turns "there is no npm token in this repository" from a
habit into something the registry enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #83, refs #75.

Publishing the CLI from the wrong directory packed the whole repository —
273 files, 10 MB, `.claude/settings.local.json` among them — and attempted
to publish it as `wave@0.1.0`. The only thing that stopped it was that name
already having a 0.1.0 on npm, owned by someone else. Nothing was published
and nothing is public; the error was npm's own conflict check, which runs
after fetching the packument.

Three links in that chain, two of them fixable here.

`"private": true` did not stop it. npm 11.11.0 packs and runs the
version-conflict check first, with or without `--access public`; both were
reproduced with `--dry-run`. So the flag is not load-bearing and a
`prepublishOnly` that exits 1 is, and it fires before anything is assembled
— there is no tarball listing in the output at all. It runs on publish only,
so install, build and CI are untouched.

`.claude/settings.local.json` was packed because `npm publish` falls back to
`.gitignore` when there is no `.npmignore`, reads only the repository's own
ignore rules, and this machine ignores that file globally. Ignored in two
places is not ignored in one: the rule is now in the repository, where npm
and anyone cloning can both see it. The file holds whatever commands were
allowed, which per #75 is eleven grants with bearer tokens and message
bodies inside them.

The third link was a shell in the wrong directory, which no file can fix.

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

vercel Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wave Ready Ready Preview Sep 28, 2026 7:10pm UTC

Refs #83, refs #75.

npm's trusted publisher can allow staging only, and that is how this one is
configured. The job runs `npm stage publish --provenance` and stops; the last
step is a maintainer running `npm stage approve` from their own machine, with
2FA, which is where the version actually reaches the registry.

The reason is what a tag is. A tag is a git ref, and with direct publishing
anyone who can push one — or a compromised workflow, or a compromised
dependency of one — reaches everyone who installs this package on their next
`npm i -g`. It exists to hold people's tokens. One approval is a fair price,
and this package will release rarely.

Provenance is unaffected: `--provenance` is a flag on the stage command, and
the attestation still says which commit produced the tarball. `--access` is
gone, because it defaults to preserving the level a package already has and
this one is public from the hand publish; it was only ever needed for the
first.

The job is renamed `stage`, since that is what it does, and it writes the two
commands that are now owed into the run summary — the place someone looks
right after pushing a tag. `cli/RELEASING.md` carries them too, with the
trusted-publisher fields as a table, including the box to leave unchecked.
Rejecting an unapproved stage is written down as well: `npm view` cannot see
one, so the workflow's already-published check will not notice it, and a
second tag would leave two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
david-sling and others added 7 commits September 28, 2026 23:11
Refs #75, refs #84.

A default-mode Claude Code session on localhost raised eight permission
dialogs for one join-and-talk loop, and seven of them offered "allow once"
only. Every one of those seven started with the prompt's pasted preamble —
`NAME=...; W=...; export WAVE_SESSION=$(cat "$W/session")` — and a command
that opens with assignments and contains a substitution is not one a prefix
rule can be written for. The only call offered "always" was the only one with
nothing in front of `wave`. The CLI's whole argument was one grant instead of
eleven, and the prompt had spent it.

So the CLI reads the file itself. `-s <file>` (long form `--session-file`)
works on every verb. `join -s` writes the session there, owner-readable, and
never prints it; it refuses a file that already holds one before anyone
joins, which is the guard the prompt used to spell in shell. `leave -s`
deletes the file, on a 410 or 401 as well since the token in it is dead, and
keeps it when the leave did not happen. `-s` beats WAVE_SESSION, and `-s`
with `--session` is refused rather than resolved.

`send --file <path>` sends a file's contents, so a diff goes in without a
pipe in front of `wave`. `-` still reads stdin for a person at a shell.
`wave --version` exists, because the prompt now needs a way to ask whether
an install is new enough to have `-s`. Version 0.2.0.

This is not the session store the state design refused. That was a path the
CLI chose, keyed on the channel, which two agents in one channel would share.
The CLI still has no path of its own; it touches a file only where the
command line names one. The no-filesystem test now holds that: `io.ts` is the
one module that reaches the disk, and nothing may look up a home or temp
directory.

Re-run with the new prompt, every call was offered "always allow". Claude
Code builds that rule from the first two words, so it is one approval per
verb rather than one in total; whether the rule survives to the next session
is not yet known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84, refs #75.

The preamble, step 1's guard and sed, the rm -rf and the stdin pipe are
gone. Every command is `wave <verb> -s <FILE> ...`, and the prompt now says
to run each exactly as written and on its own, because a default-mode agent
also appended `; echo "exit $?"` and `| head` of its own accord, and either
one breaks a rule as surely as the preamble did.

The page fills FILE in from the agent name, `/tmp/wave-<channel>-<name>`,
with the Windows spelling beside it, so an agent that keeps its name computes
nothing. Step 0 asks for `wave --version` 0.2.0 or later, and a test holds
that at or below the CLI's own version so the prompt cannot ask for a flag
nobody can install.

The tests that pinned the preamble are replaced by one that holds every
command line to `-s <FILE>` with no `$`, pipe, semicolon, `&&`, backtick or
redirect in it. ARCHITECTURE section 11 and PRODUCT section 7 record the
dialog count as the reason, and the PRODUCT block is the new template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to curl

Refs #84, refs #75.

The prompt box gains an Agent choice beside the method: Any agent, the
default, or Claude Code. It changes one thing, the client: `CLIENT` and
`--client` become `claude-code` instead of a blank for the agent to fill.
The prompt is now a function of name, goal, method and agent.

The CLI prompt no longer says how often a tool will ask for permission.
"Allow wave once" was measured false — Claude Code saves one rule per verb
per project, `Bash(wave <verb> *)` — and a paragraph describing one product's
dialogs is the tool's behaviour to describe, not the prompt's to promise.
The measurement stays in PRODUCT and ARCHITECTURE as an observation. What
stays in the prompt is the instruction that produced the result: run each
command exactly as written, on its own.

Step 0 now names a fallback for an agent that cannot install or run `wave`
— no Node 20, no npm, a sandbox that blocks either: `/agent/curl.md`, the
curl prompt. It is generated from the curl template like the index is, so
the two cannot drift, with the channel, the invite and the name as
placeholders the agent fills from the join URL it already has. The invite
never goes into a URL a server sees. An agent that already joined with wave
is told to leave first, and the fallback keeps the goal from the prompt that
sent it there.

The agent-docs link test now scans both templates, so a dead link in either
fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84.

The prompt box's two choices were two full-width segmented tracks, one
under the other. They are now one line that reads as a sentence — For
[agent] via [method] — with a hairline between the halves. The agent
options are marks rather than words: a terminal glyph for any agent and
Claude's own mark, unchosen ones in grey, each named in an aria-label and
in a tooltip that answers hover and keyboard focus. The method options are
word chips, and "wave CLI" is called npm, which is what the person actually
installs from. An encrypted channel states the method instead of offering
it.

Both choices persist per device in localStorage. They are read through
useSyncExternalStore, so the server render and the first client render
agree on the default, and the two prompt boxes the channel page mounts move
together. A stored value that is not an option is ignored, and a choice
that storage refuses is kept in memory for the page.

Every note under the line is laid out in one grid cell with only the
current one visible, so switching method no longer resizes the dialog.
Options are 34px, 44px on coarse pointers. DESIGN.md records the control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84.

The line with a hairline between its halves did not separate them enough.
Each setting is now its own capsule, the options flush inside it and split
by straight hairlines, so the capsule says which options belong together
and the "For" and "via" captions are gone. The legends stay for screen
readers, and the marks keep their names in aria-labels and tooltips.

The chosen option is pressed in — line-2 fill and a 1px inset shadow —
rather than lifted: a lifted shadow is clipped by its neighbours in a joined
group, and ink stays reserved for Copy prompt. The end options carry the
capsule's round corners themselves, so nothing clips with overflow and the
tooltip is never cut off. The focus ring is inset to stay inside the
capsule. An encrypted channel shows only the agent group; the note below
already says it uses the wave command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84, refs #75.

Nothing told the person how to install the CLI. The prompt told the agent
to run `npm i -g` itself, which is a global install outside its workspace
— the kind of change rule 4 says to confirm first — and the step a sandbox
is most likely to refuse. The page said only "one install on the agent's
machine".

Step 0 now has the agent run `wave --version` and, if wave is missing or
older than 0.2.0, ask its user to install it and say when it is done,
rather than installing it itself. If that cannot happen, it falls back to
the curl prompt as before.

The method choice is curl, npm, pnpm, yarn or bun. Every package manager
gives the same CLI prompt; which one sets the install command the person is
shown, with its own copy button under the choice, and the one the prompt
names in step 0 (`npm i -g`, `pnpm add -g`, `yarn global add`, `bun add -g`).
An encrypted channel offers the four without curl. Every note is still laid
out in one grid cell, so switching among five does not resize the dialog,
and a hidden note's copy button cannot take focus.

`yarn global add` is Yarn 1 only, and Bun links a binary that still runs on
Node; both are said in the READMEs. The main README gains a section on the
CLI: optional, curl stays the default, and how to install it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
david-sling and others added 3 commits September 28, 2026 23:54
…ds over

Refs #84.

The dialog is where an agent is handed into the room, and it opted out of
everything the rest of the system does at strength. The title is now
Funnel Display 700 at 20px in a single bar. The frame is 600px on the panel
radius.

The prompt preview is no longer blurred: 11px mono in ink-2, fading into
the ground, with the agent's name marked in lilac-soft wherever the prompt
carries it, so editing the name visibly rewrites what is handed over. Copy
prompt is the full 48px ink pill, the one primary action at full size.
The empty channel's prompt box shares the footer and the button.

The taller dialog pushed Copy prompt off a short window, so the header and
the prompt footer are now fixed and only the settings scroll between them.
On a phone the method group takes its own row and shares it evenly, and the
note grid's column can shrink, which stops a hidden yarn command from
widening the box.

A signal-field header was tried and dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84.

Copy prompt was a pill floating over the middle of the fading preview. It
is now a 48px ink bar spanning the footer under the preview: a copy icon and
the label at the start, and at the far end what it takes — the prompt's
line count and the agent it is for, live as the settings change, truncated
before it can wrap. Copied swaps the icon for the check and turns the bar
green, as every copy control does.

It does not lift on hover. A bar that moves reads as the whole footer
moving, so `.btn-bar` drops the primary button's translate and shadow and
steps its fill 12% toward panel instead. Every other primary button keeps
its lift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #84.

The bar's pill ends did not follow the dialog's curve. It now sits 10px
from the frame's edges and is concentric with it: its radius is the frame's
less the 1px border and the gap, read from `--frame-radius`, so it is 17px
in the 28px dialog and follows the empty state's 16px box too, floored at
6px. The dialog's radius is unchanged.

The dialog's inner cap now sits under the browser's own max height for a
modal (100% less 2em and 6px); above it, the dialog clipped the bottom of
the footer, and the bar sat 2px from the edge instead of 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #75.

The CLI branch added roughly 630 lines of comments: doc blocks restating
names, rationale and design history, measurements, references to doc
sections and commits. All of that is in the commit messages, PRODUCT and
ARCHITECTURE, where it belongs. About fourteen stay, one line each, where
the code cannot say it and a reader would otherwise break something: the
ES5 Node guard, exitCode over process.exit, 401 treated as gone, the fresh
client_id the retry relies on, --timeout 0 still reading once, the copied
types, the invite never filled into the public curl doc, the release's
missing npm token and npm upgrade, the useId radio groups, the shared note
cell, the dialog's flex wrapper and height cap, and the bar's concentric
radius and unclipped ends.

No code changed: every TS, CSS and YAML file is identical to before once
comments are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
david-sling and others added 4 commits September 29, 2026 00:20
Refs #75.

An agent reads these, so each one now names the problem and the recovery.

- join -s writes an empty placeholder before joining, so a path that cannot
  take the session fails before a participant exists. It used to join, then
  fail the write, and leave a participant nobody held a session for. The
  placeholder goes if the join fails, and a write that still fails after
  joining prints the session and the leave command that undoes it.
- A 401 from join is a bad invite, not a channel that has gone: exit 1 and
  "copy the whole URL again", where it was exit 5, which the prompt tells an
  agent means stop. A 401 anywhere else is still 5.
- Network failures name the host and the cause (connection refused, no such
  host, timeout, reset, TLS) with what to check, instead of "fetch failed".
  A host that answers with something other than JSON is said to be possibly
  not a Wave instance.
- File errors are plain words: "Cannot write <path>: permission denied."
- Usage errors end with the command's own usage line, and an unknown option
  lists the ones that command takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #75.

A reply printed exactly like any other message, so an agent answered from
several messages back could not tell that it was being answered. wait and
tail now print "[12] Name (reply to 9): ..." and add "mentions you" when the
text names the reader with @. Only the number is shown, never the message
it answers: the agent looks back itself if it needs to.

The reader's name comes from the roster each poll returns, so the session
string is unchanged. Mentions are found by a copy of the app's findMentions,
longest roster name first, and tests/cli-mentions.test.ts holds the two to
the same answers. --json is unchanged; it already carried reply_to. The CLI
prompt's step 3 says how both read.

The curl prompt's watcher is not changed here: it knows the agent's id, not
its display name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #75.

`npm run test:agents`, against a running instance (WAVE_HOST, default
localhost:3000). It builds the CLI, creates a channel, prints its link, and
starts four headless `claude -p` sessions in default permission mode, each
allowed only `Bash(wave *)` plus file read and write, with the prompt the
channel page would give them for Claude Code over npm. A Lead supervises a
backend, a frontend and a reviewer agent agreeing the shape of a cancelled
order, and each role's duties exercise a feature: who, --reply-to, --file,
the secret filter, --done, leave, and the Lead leaving last.

It streams the conversation to the terminal, then checks from the
transcript and each agent's own tool calls that every agent joined, spoke,
finished and left, ran nothing but plain wave commands with no permission
denials, and that each feature was used; it exits 1 if any check fails.
Logs and report.json go under $TMPDIR/wave-agent-runs. Not in CI.

The first run passed 29 of 30 for $5.55: the Lead wrote a file with a shell
heredoc, which was denied.

tsconfig allows .ts import extensions so Node can run the harness directly
and still import the real prompt builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a phone

Refs #84.

An OS choice now comes before the agent: Any OS, macOS, Linux or Windows,
as marks with tooltips like the agent's, remembered per device. It changes
only what differs by platform. The curl prompt's paragraph about POSIX
shell, jq and /agent/windows.md stays for Any OS and Windows and goes on
macOS and Linux. The CLI prompt's FILE is /tmp/... on macOS and Linux,
%TEMP%\... on Windows, and both for Any OS. The agent run passes the
machine's own OS.

The marks are Simple Icons glyphs: Apple and Linux from 16.33.0, Windows
from 12.4.0, the last release that carried it. Any OS is a drawn desktop
icon. Mark tiles are 38px so all three groups fit one line in the 600px
dialog, and 44px on a phone, where the methods take their own row.

Below sm, Add an agent is a bottom sheet: full width, flush with the bottom
edge, rounded only at the top, padded for the safe area, capped at 92dvh,
and sliding up exactly as the channel menu's sheet does. From sm up it is
the same centred modal. `.dialog-adaptive` switches the one dialog rather
than mounting a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@david-sling
david-sling merged commit 304cca7 into main Sep 28, 2026
6 checks passed
@david-sling
david-sling deleted the wave-cli branch September 28, 2026 19:11

This branch was successfully deployed

1 active deployment
Preview — 5750d180 Deployed Sep 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant