Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3c1d326
Clear the temp directories the join-prompt test leaves behind
david-sling Sep 16, 2026
32dacbb
Give the CLI a package that refuses to run on the wrong Node
david-sling Sep 16, 2026
73cf157
Put the session in a string and the cursor in the output
david-sling Sep 16, 2026
85c2cb7
Let one URL be everything join needs
david-sling Sep 16, 2026
71a6fd1
Let send take a diff on stdin and a refusal as an answer
david-sling Sep 16, 2026
24b578e
Hold the whole wait in one call, and hand back the cursor with it
david-sling Sep 16, 2026
3417f3d
Let an agent leave, and ask who is still here
david-sling Sep 16, 2026
6d87b72
Fail in the app when the CLI's copy of a shape stops matching
david-sling Sep 16, 2026
9eb40cd
Publish the CLI from a tag, with no token to leak
david-sling Sep 16, 2026
3fffd52
Offer the CLI prompt beside the curl one, and keep the token out of it
david-sling Sep 16, 2026
65b0b94
Say in the plan that the CLI is built
david-sling Sep 16, 2026
96d520b
Say that the first release cannot come from the workflow
david-sling Sep 16, 2026
511ec97
Stop the repository from being publishable by accident
david-sling Sep 16, 2026
f7432ed
Stage the release from the tag, and let a person publish it
david-sling Sep 16, 2026
2d97cc2
Let the CLI read the session from a file the caller names
david-sling Sep 28, 2026
21c965a
Make every command in the CLI prompt plain wave and nothing else
david-sling Sep 28, 2026
1e03feb
Ask which agent the prompt is for, and give the CLI prompt a way back…
david-sling Sep 28, 2026
23f4254
Put the agent and the method on one line, and remember both
david-sling Sep 28, 2026
9534e80
Group the agent and the method as two joined button groups
david-sling Sep 28, 2026
3c20cc9
Show the person the install, and let them pick their package manager
david-sling Sep 28, 2026
fe961c6
Merge remote-tracking branch 'origin/main' into wave-cli
david-sling Sep 28, 2026
f3ae8cf
Give the add-agent dialog a display title, and show the prompt it han…
david-sling Sep 28, 2026
fca7740
Make Copy prompt a bar that says what it copies
david-sling Sep 28, 2026
8c32590
Round the Copy prompt bar with the frame it sits in
david-sling Sep 28, 2026
ac94e07
Strip the comments the code already explains
david-sling Sep 28, 2026
68c97eb
Make every CLI error say what to do next
david-sling Sep 28, 2026
3576328
Mark replies and mentions of the reader in wait and tail
david-sling Sep 28, 2026
fc11a1e
Add a local run that has real Claude Code agents use the CLI
david-sling Sep 28, 2026
5750d18
Ask which OS the agent runs on, and open Add an agent as a drawer on …
david-sling Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,23 @@ jobs:
- run: npm run test:integration
env:
REDIS_URL: redis://localhost:6380

cli:
name: cli (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 24]
defaults:
run:
working-directory: cli
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
60 changes: 60 additions & 0 deletions .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Release CLI

on:
push:
tags: ['cli-v*']

jobs:
stage:
runs-on: ubuntu-latest
permissions:
# OIDC for npm trusted publishing; there is deliberately no npm token.
id-token: write
contents: read
defaults:
run:
working-directory: cli
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: cli/package-lock.json

# Trusted publishing needs npm >= 11.5.1, newer than some Node 24 images ship.
- run: npm install -g npm@latest

- run: npm ci

- name: The tag and the package must agree
run: |
tagged="${GITHUB_REF_NAME#cli-v}"
declared="$(node -p "require('./package.json').version")"
if [ "$tagged" != "$declared" ]; then
echo "Tag $GITHUB_REF_NAME says $tagged, package.json says $declared." >&2
exit 1
fi

- name: Refuse a version that is already published
run: |
version="$(node -p "require('./package.json').version")"
if npm view "@david-sling/wave@$version" version >/dev/null 2>&1; then
echo "@david-sling/wave@$version is already on npm. Bump the version and tag again." >&2
exit 1
fi

- run: npm run typecheck
- run: npm test

- run: npm stage publish --provenance

- name: Say what is now owed
run: |
echo "Staged. Nothing is on npm until this is approved from a machine with 2FA:" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "npm stage list @david-sling/wave" >> "$GITHUB_STEP_SUMMARY"
echo "npm stage approve <stage-id>" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ next-env.d.ts
# M0 validation spike: throwaway, per docs/PLAN.md
/spike/

# Per-machine agent permissions. Ignored globally on the machine this was
# written on, which is not the same as ignored here: `npm publish` falls back
# to this file when there is no .npmignore, reads only the repository's own
# ignore rules, and packed this file once because of the difference. It holds
# whatever commands were allowed, tokens and message bodies included.
.claude/settings.local.json

/cli/node_modules
/cli/dist
20 changes: 20 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,26 @@ The landing page's main call to action, in the hero heading block: a 48px `.inpu
- **Focus:** 2px accent outline on the visible span.
- **Rail (`.segmented-rail`):** below `sm` a track with more segments than fit becomes one scrolling row instead of stacking into a block — the inner element is a flex scroller with the scrollbar hidden and `scroll-behavior: smooth`, and segments take 14px of side padding so they size to their labels. From `sm` it is `display: contents`, so the labels are the track's own grid items again and nothing about the desktop control changes. Both ends are masked to transparent over 8px, narrower than a segment's padding, so the fade lands on the gap rather than on a word and a half-visible next label reads as a row that continues.

### Add an agent dialog (`channel/add-agent-dialog.tsx`)

- **Frame:** below `sm`, a bottom sheet: full width, flush with the bottom edge, 28px top corners and square bottom ones, padded for the safe area, capped at 92dvh, sliding up like the channel menu's sheet (`.dialog-adaptive`). From `sm` up, a `.dialog-modal` at `min(92vw, 600px)`, 28px panel radius, `shadow-lift`, capped at the viewport height less 3rem, under the browser's own cap for a modal. The header and the prompt footer are fixed and the settings between them scroll, so Copy prompt is on screen at any window height.
- **Header:** one bar, 1px `line` rule below. "Add an agent" in Funnel Display 700 at 20px, tracking -0.015em, and a 32px close control, ink-3, `panel-2` on hover. No subtitle: the room is spent on the settings and the prompt, which is what the dialog is for.
- **Prompt footer:** the prompt itself on `ground`, 11px Geist Mono in ink-2, not blurred, fading to transparent over its 120px. The agent's name is marked in `lilac-soft` wherever the prompt carries it, so editing the name visibly rewrites what is handed over. Under it, Copy prompt is a 48px ink bar spanning the footer, 10px from the frame's edges and concentric with it: its radius is the frame's less the 1px border and the 10px gap (`--frame-radius`, `--frame-inset`), 17px in the dialog. a 16px copy icon and the label at the start (the icon becomes the check once copied), and at the far end what it takes — "90 lines · for David's agent", 13px 500 at 60% — truncated before it can wrap. It does not lift on hover, since a bar that moves reads as the footer moving; its fill steps 12% toward panel instead (`.btn-bar`). Copied turns it `ok` green like every copy control. The empty channel's prompt box shares the footer, not the header.

### Choice groups (`.choices`, `.choice-group`, `.choice`, in `channel/prompt-box.tsx`)

The prompt box's three settings, OS, agent and method, as joined button groups side by side, in that order. Used where a choice is small, has two or three options, and belongs beside another choice rather than stacked under a label.

- **Group:** a `fieldset` drawn as one capsule — white fill, 1px `line` border, pill radius — with a screen-reader legend and no visible caption: the capsule is what says which options belong together. Groups sit 10px apart and wrap as whole groups.
- **Option:** flush inside the capsule, 34px tall (44px on coarse pointers), 16px side padding, 14px 500 ink-3 text, split from its neighbour by a straight 1px `line` hairline. The end options carry the capsule's round corners themselves, so nothing needs overflow clipping and the tooltip is never cut off. Hover: `panel-2` fill, ink-2 text.
- **Chosen:** pressed in, not lifted — `line-2` fill, a 1px inset shadow, ink text. A lifted shadow would be clipped by its neighbours in a joined group, and ink stays reserved for the box's one primary action.
- **OS marks:** a drawn desktop icon for "Any OS", and the Simple Icons Apple, Linux and Windows glyphs (`app/components/platform-marks.tsx`), Windows in its own blue and the others in ink, all greyscale until chosen like every mark.
- **Mark option (`.choice-mark`):** 38px wide (44px on a phone), holding a 17px mark: `ClientMark` for a product, an authored icon for "any". Unchosen marks are greyscale at 55% opacity, so the chosen one is the only mark in its own colour.
- **Tooltip (`.choice-tip`):** a mark carries its name in `aria-label` and in an ink tooltip, 12px 500 panel text, 6px radius, 8px above the option. It answers hover and `:focus-visible` both, after 120ms, rising 3px as it fades in.
- **Focus:** 2px accent outline, inset 2px, so it stays inside the capsule.
- **Note:** the line under the groups lays every variant in one grid cell and shows only the current one, so switching never changes the box's height.
- **Memory:** both choices persist per device in localStorage (`useRemembered`, `channel/remembered.ts`), and every box on the page reading the same key moves together.

### Nav veil (`nav-veil.tsx`)
The blurred layer behind the nav, and the one thing on it that reacts to the page. It is its own layer rather than the header's background, because the mask that fades it out would otherwise fade the nav's own pill and wordmark with it.

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ Wave is transport, not orchestration. Each agent still takes its goals from its
| Antigravity CLI | approve the shell command once |
| Anything with a shell | HTTP and a loop, nothing more |

## The wave CLI

Optional. The curl prompt stays the default and needs nothing installed. If you would rather your agent use a client — fewer permission prompts, and one tool call per wait instead of one per poll — install it once on the agent's machine (Node 20 or later):

```bash
npm i -g @david-sling/wave
```

`pnpm add -g`, `yarn global add` (Yarn 1) and `bun add -g` work too. Then choose your package manager in the channel's *Add an agent* box, and the prompt your agent gets uses `wave` instead of `curl`. The agent checks for it and asks you to install it if it is missing, rather than installing it itself. More in the [CLI's README](cli/README.md).

## Self-hosting

Wave is a Next.js app with Redis behind it. Any Node.js host that allows a 60-second request and any Redis 6 or later will do.
Expand Down
6 changes: 4 additions & 2 deletions app/agent/[topic]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig } from '@/lib/config'
import { AGENT_DOCS, INDEX_TOPIC, agentDocIndex, findAgentDoc, readAgentDoc } from '@/lib/agent-docs'
import { AGENT_DOCS, CURL_TOPIC, INDEX_TOPIC, agentDocIndex, findAgentDoc, readAgentDoc } from '@/lib/agent-docs'
import { curlPromptDoc } from '@/lib/join-prompt'

/**
* The capability docs, as the agents fetch them (PRODUCT section 7.1).
Expand All @@ -17,7 +18,7 @@ export const dynamic = 'force-static'
export const dynamicParams = true

export function generateStaticParams() {
return [{ topic: `${INDEX_TOPIC}.md` }, ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` }))]
return [{ topic: `${INDEX_TOPIC}.md` }, { topic: `${CURL_TOPIC}.md` }, ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` }))]
}

/** `receipts.md` and `receipts` are the same document: an agent will try both. */
Expand All @@ -39,6 +40,7 @@ function markdown(body: string): Response {
export async function GET(_request: Request, context: RouteContext<'/agent/[topic]'>): Promise<Response> {
const topic = toTopic((await context.params).topic)
if (topic === INDEX_TOPIC) return markdown(agentDocIndex(getConfig().host))
if (topic === CURL_TOPIC) return markdown(curlPromptDoc(getConfig().host))

const doc = findAgentDoc(topic)
if (!doc) {
Expand Down
38 changes: 23 additions & 15 deletions app/components/channel/add-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ export function AddAgentDialog({
channelId,
channelName,
invite,
mode,
}: {
open: boolean;
onClose: () => void;
host: string;
channelId: string;
channelName: string;
invite: string;
mode?: string;
}) {
const dialog = useRef<HTMLDialogElement>(null);

Expand All @@ -46,24 +48,30 @@ export function AddAgentDialog({
// the content sits in a child that stops it.
if (event.target === dialog.current) onClose();
}}
className="dialog-modal m-auto w-[min(92vw,560px)] rounded-[20px] border border-line bg-panel p-0 text-ink"
className="dialog-modal dialog-adaptive m-auto w-[min(92vw,600px)] overflow-hidden rounded-[28px] border border-line bg-panel p-0 text-ink [--frame-radius:28px] max-sm:mx-0 max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:border-b-0 max-sm:pb-[env(safe-area-inset-bottom)]"
aria-labelledby="add-agent-heading"
>
<div className="flex items-center justify-between gap-4 border-b border-line px-4 py-3">
<h2 id="add-agent-heading" className="m-0 font-sans text-[15px] font-semibold">
Add an agent
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-8 place-items-center rounded-[9px] text-ink-3 transition-colors hover:bg-panel-2 hover:text-ink"
>
<CloseIcon />
</button>
</div>
{/* Flex on a wrapper, since a display class on <dialog> overrides display: none; max-h stays under the modal cap or the footer clips. */}
<div className="flex max-h-[calc(100dvh-3rem)] flex-col max-sm:max-h-[calc(92dvh-env(safe-area-inset-bottom))]">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-line px-5 py-3.5">
<h2
id="add-agent-heading"
className="m-0 font-display text-[20px] font-bold leading-tight tracking-[-0.015em]"
>
Add an agent
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-8 place-items-center rounded-[9px] text-ink-3 transition-colors hover:bg-panel-2 hover:text-ink"
>
<CloseIcon />
</button>
</div>

<PromptBox host={host} channelId={channelId} channelName={channelName} invite={invite} />
<PromptBox host={host} channelId={channelId} channelName={channelName} invite={invite} mode={mode} />
</div>
</dialog>
);
}
5 changes: 3 additions & 2 deletions app/components/channel/channel-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,8 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri
time, then watch them here.
</p>
</div>
<div className="overflow-hidden rounded-[16px] border border-line bg-panel-2">
<PromptBox host={host} channelId={channelId} channelName={channel.name} invite={invite} />
<div className="overflow-hidden rounded-[16px] border border-line bg-panel-2 [--frame-radius:16px]">
<PromptBox host={host} channelId={channelId} channelName={channel.name} invite={invite} mode={channel.mode} />
</div>
</div>
)}
Expand Down Expand Up @@ -549,6 +549,7 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri
channelId={channelId}
channelName={channel.name}
invite={invite}
mode={channel.mode}
/>

{error ? (
Expand Down
35 changes: 24 additions & 11 deletions app/components/channel/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { TextMorph } from "torph/react";
import { CheckIcon } from "../icons";
import { CheckIcon, CopyIcon } from "../icons";
import { copyText } from "./copy-text";

/**
Expand All @@ -17,10 +17,14 @@ export function CopyButton({
value,
label,
variant = "primary",
size = "sm",
detail,
}: {
value: string;
label: string;
variant?: "primary" | "secondary";
size?: "sm" | "md";
detail?: ReactNode;
}) {
const [state, setState] = useState<"resting" | "copied" | "failed">("resting");
const copied = state === "copied";
Expand All @@ -37,23 +41,32 @@ export function CopyButton({
return (
<button
type="button"
className={`btn btn-sm gap-2 ${tone}`}
className={`btn ${size === "sm" ? "btn-sm" : ""} ${detail === undefined ? "gap-2" : "btn-bar w-full justify-start gap-2 pl-6 pr-5"} ${tone}`}
aria-live="polite"
onClick={async () => {
setState((await copyText(value)) ? "copied" : "failed");
}}
>
<span
aria-hidden
className={`grid overflow-hidden transition-[width,opacity] duration-200 motion-reduce:transition-none ${
copied ? "w-4 opacity-100" : "w-0 opacity-0"
}`}
>
<CheckIcon className={copied ? "check-pop" : ""} />
</span>
{detail === undefined ? (
<span
aria-hidden
className={`grid overflow-hidden transition-[width,opacity] duration-200 motion-reduce:transition-none ${
copied ? "w-4 opacity-100" : "w-0 opacity-0"
}`}
>
<CheckIcon className={copied ? "check-pop" : ""} />
</span>
) : (
<span aria-hidden className="grid w-4">
{copied ? <CheckIcon className="check-pop" /> : <CopyIcon />}
</span>
)}
<TextMorph duration={320} ease="cubic-bezier(0.19, 1, 0.22, 1)">
{state === "copied" ? "Copied" : state === "failed" ? "Select it instead" : label}
</TextMorph>
{detail === undefined ? null : (
<span className="ml-auto min-w-0 truncate pl-4 text-[13px] font-medium opacity-60">{detail}</span>
)}
</button>
);
}
34 changes: 34 additions & 0 deletions app/components/channel/install-command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { useEffect, useState } from "react";
import { CheckIcon, CopyIcon } from "../icons";
import { copyText } from "./copy-text";

export function InstallCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);

useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 2_000);
return () => clearTimeout(timer);
}, [copied]);

return (
<div className="install-command">
<code>
<span aria-hidden className="text-ink-3">
${" "}
</span>
{command}
</code>
<button
type="button"
className="install-copy"
aria-label={copied ? "Copied" : "Copy the install command"}
onClick={async () => setCopied(await copyText(command))}
>
{copied ? <CheckIcon size={15} className="check-pop" /> : <CopyIcon size={15} />}
</button>
</div>
);
}
Loading
Loading