Repository navigation
Wave CLI - #86
Merged
Merged
Wave CLI#86
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
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>
# Conflicts: # .gitignore
…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>
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 forstandardchannels. Published as@david-sling/wave.The driver is permission grants, not ergonomics. The M0 spike left eleven grants in one
.claude/settings.local.jsonagainst a single host, each the full command with the bearer token, theafter=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.waveinverts 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.joinprints an opaque session string carrying host, channel, participant and token; every later command takes it back. A test scans all ofsrc/and fails on an import ofnode: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
joinoverwriting 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.
waitandtailend 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
jointakes 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.sendreads 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.waitreissues 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.tsis a copy, not an import.tests/cli-types.test.tsholds it against the zod schemas and the response types — it lives here because this is where a schema changes, and incli/it would fail on the next CLI change instead.tests/cli.test.tsdrives the CLI through the real route handlers with no server and no network, since the CLI'sfetchis 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
cli-v0.1.1: the first release through the workflow, and the first with provenance.