267 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
39929eb7fe docs(roadmap): expand v0.3.0 per-topic encryption into three phases
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Phase 1 (notification table) and phase 2 (schema + creator seal)
shipped today. Phase 3 (member-driven re-seal + client-side
encrypt/decrypt) is the cut that actually flips the broker to
ciphertext-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:28:37 +01:00
Alejandro Gutiérrez
da5103a315 feat(broker+api): per-topic symmetric keys — schema + creator seal
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.

Schema 0026:
  + topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
  + topic_message.body_version  (integer, 1=plaintext / 2=v2 cipher)
  + topic_member_key            (id, topic_id, member_id,
                                 encrypted_key, nonce, rotated_at)

API:
  + GET /v1/topics/:name/key — return the calling member's sealed
    copy. 404 if no copy exists yet (joined post-creation, no peer
    has re-sealed). 409 if the topic is legacy unencrypted.

Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.

Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:28:10 +01:00
Alejandro Gutiérrez
1a238d4178 feat(api+broker+web): write-time mention fan-out via notification table
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan
in /v1/notifications and the dashboard MentionsSection with reads
from a new mesh.notification table populated at write time.

Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id,
recipient_member_id, sender_member_id, kind, created_at, read_at)
with a unique (message_id, recipient) so a re-fanned message yields
one row per recipient. Backfills existing v0.2.0 messages by
regex-matching the (still-base64-plaintext) bodies — guarded with
a base64 + length check so binary ciphertext doesn't crash the
migration.

Writers (POST /v1/messages + broker appendTopicMessage) now
extract @-mentions from either an explicit `mentions: string[]`
on the request OR a regex over the base64 plaintext (transitional
fallback). Targets are intersected with the mesh roster + capped
at 32 per message. Web chat panel sends the explicit array now so
it keeps working after phase 2 lands.

Readers switch to JOIN-on-notification:
  /v1/notifications      — table-backed, supports ?unread=1
  POST /v1/notifications/read  — new, mark by ids or all-up-to
  MentionsSection (RSC) — same JOIN, returns readAt for each row

GET /v1/notifications also gains a read_at field per row so a
future bell UI can show unread vs read.

Once per-topic encryption (phase 2) lands, the regex fallback
becomes a no-op for v2 messages — clients MUST send `mentions`,
which they already do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:23:50 +01:00
Alejandro Gutiérrez
81f8066f99 docs(roadmap): mark v1.7.0 CLI parity shipped
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Adds the terminal verbs (topic tail / member list / notification
list) explicitly to v1.7.0 so the demo cut summary matches what's
on npm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:02:59 +01:00
Alejandro Gutiérrez
dd80d4e946 feat(cli): v1.7.0 — terminal parity for SSE + members + mentions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Three new verbs that wrap the v1.6.x REST surface:

  claudemesh topic tail <name>  → live SSE consumer with N-message backfill
  claudemesh member list        → mesh roster decorated with online state
  claudemesh notification list  → recent @-mentions of you across topics

Each command auto-mints a 5-minute read-only apikey via the WS
broker and revokes on exit, so users don't manage tokens. SSE
client uses fetch + ReadableStream so the bearer stays in the
Authorization header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:02:29 +01:00
Alejandro Gutiérrez
c31a591681 docs(handoff): 2026-05-02 evening — v1.6.x + v1.7.0 demo cut state
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Companion to the morning handoff. Captures the 12 commits shipped
this evening, live deployment status, the CLI/UI surface gap, three
known risks (chiefly: mentions query depends on plaintext-base64
ciphertext + crashes on non-UTF8 bytes), and three branches for
the next session ranked by leverage: record the demo, wire CLI
verbs to the new endpoints, then v0.3.0 per-topic encryption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:35:12 +01:00
Alejandro Gutiérrez
a2ab7de60a docs(marketing): refresh timeline 'what's next' for v2.0.0 + v0.3.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Old next-block listed dashboard (shipped), slack bridge (still
v0.3.0), self-host (v0.3.0), SSO (out of scope). Replaces with
the actual roadmap horizon: daemon redesign, per-topic crypto,
self-host packaging, federation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:33:51 +01:00
Alejandro Gutiérrez
69cf39bc9f docs(blog+demo): v1.7.0 launch post + 90s demo script
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Blog post "Agents and humans in the same chat" walks through what
shipped in the v1.7.0 demo cut: topics, REST gateway, real-time
SSE, mentions, notification feed, humans-as-peers. Linked from
the blog index above the original protocol post.

Demo script lays out a five-scene 90-second screen capture: two
terminal agents talking, dashboard topic list, live chat with
@-mention autocomplete, mentions feed cross-platform, close.
Production notes + distribution checklist included.

Marketing screenshots and the actual recording are still TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:32:35 +01:00
Alejandro Gutiérrez
0ab2bea045 docs(roadmap): mark /v1/peers humans-as-peers as shipped
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Bridge smoke test is the last remaining v1.6.x item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:29:03 +01:00
Alejandro Gutiérrez
f4601f4d9c feat(api): humans-as-peers in /v1/peers
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Recently-active apikey holders (used in the last 5 minutes) appear
in the peer list alongside WS-connected sessions. The dashboard
chat user now becomes visible to CLI peers calling list_peers,
closing the v1.6.0 humans-as-peers loop.

Presence rows take precedence when both exist; rest-only rows
get via:"rest" flag and idle status (no presence channel to
infer working/dnd from).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:28:47 +01:00
Alejandro Gutiérrez
a83133a4c6 docs(roadmap): mark v1.6.x SSE/unread + v1.7.0 sidebar/mentions/feed shipped
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Updates v1.6.x and v1.7.0 sections with concrete endpoints + client
behaviour for what landed this session. Bridge smoke test and
/v1/peers humans remain open under v1.6.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:27:44 +01:00
Alejandro Gutiérrez
a9160a0965 feat(api+web): notification feed — recent @-mentions across meshes
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Universe dashboard gets a "Recent mentions" section listing every
topic_message from the last 7 days that references the viewer via
`@<displayName>` (per-mesh — a user can carry different display
names in different meshes). One union'd OR query, capped at 20.

Each mention card links straight into the topic chat at the right
mesh. Snippet is the first 240 chars of the decoded ciphertext with
@-tokens highlighted in clay, matching the in-chat renderer.

GET /v1/notifications mirrors the same scan for api-key-authed
clients (CLI, bots) — accepts ?since=<ISO> for incremental polling.
Both paths use Postgres regex on the decoded base64 plaintext;
when per-topic encryption lands in v0.3.0 they'll move to a
notification table populated at write time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:26:02 +01:00
Alejandro Gutiérrez
00c25d9803 feat(web): client-side search filter in topic chat
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
A "search" toggle in the chat header opens a small input that
client-filters loaded messages by plaintext match on body or
sender name. Live tail auto-scroll suspends while a query is
active so matches stay visible when new messages arrive.

Server-side fulltext search lands when ciphertext moves to
per-topic symmetric keys in v0.3.0 — until then there's no
server index to query, and the loaded window (last 100 plus
forward stream) covers most "find that thing from earlier"
needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:23:21 +01:00
Alejandro Gutiérrez
35a289b64a feat(web): @-mention autocomplete + highlight in topic chat
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Typing `@` in the compose box opens a dropdown of matching mesh
members fed by /v1/members. Filters live by displayName prefix
(case-insensitive); online members rank above offline; shorter
names rank higher; capped at 8 entries.

Keyboard: ArrowUp/Down to navigate, Enter or Tab to insert,
Escape to dismiss. Mouse hover updates the selection; mousedown
inserts (mousedown so the textarea doesn't lose focus first).

Rendered messages now highlight @mentions in clay so they're
visually distinct from plain text — same regex the autocomplete
uses, so the round trip is consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:21:19 +01:00
Alejandro Gutiérrez
7af61e121e fix(web): stop SSE reconnect loop on 4xx errors
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
A revoked api key or missing topic returned by GET /v1/.../stream
used to throw inside the catch and bounce through the backoff loop
forever. Now any 4xx response terminates the loop and surfaces the
status + body in the panel error so the user sees the real cause.
5xx and network errors still reconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:19:25 +01:00
Alejandro Gutiérrez
a75483b3c2 feat(api+web): member sidebar in topic chat with live presence
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
GET /v1/members lists every non-revoked member of the api key's
mesh, decorated with online state from presence rows. Distinct from
/v1/peers (active sessions) — sidebars want roster + live dot, not
just whoever is currently connected.

Chat panel splits into a 2-column layout (>=lg) with a 180px
sidebar that polls the roster every 20s. Online members go up top
with status-coloured dots (idle=green, working=clay, dnd=fig);
offline members fade below at 50% opacity. Bots get a "bot" tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:10:26 +01:00
Alejandro Gutiérrez
541440c357 feat(web): unread badge on dashboard mesh cards
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Universe page aggregates unread topic_message rows per mesh for the
viewing user. Counts messages newer than topic_member.last_read_at
(or all messages if the viewer never opened the topic) and excludes
anything the viewer authored. One JOIN-grouped query, not N+1.

Mesh card surfaces the count as a clay-rounded badge to the left of
the role chip — matches the per-topic badge style on the mesh detail
page so unread is the same visual idiom across the dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:08:11 +01:00
Alejandro Gutiérrez
a80eb6fcca feat(api+web): unread counts per topic + PATCH /read mark-as-read
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
PATCH /v1/topics/:name/read upserts topic_member.last_read_at for the
api key's issuing member. The chat panel calls it on mount and on
every inbound SSE message (5s debounce so we don't hammer it).

GET /v1/topics now returns unread per topic — counts messages newer
than last_read_at and not authored by the viewer. Mesh detail page
shows a clay-rounded badge next to each topic name with the count
(99+ ceiling).

AuthedApiKey gains issuedByMemberId so endpoints can attribute
side-effects to the minting member. Required because external api
keys aren't tied to a specific peer member; only dashboard- and
CLI-minted keys carry one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:06:01 +01:00
Alejandro Gutiérrez
7e71a61db4 feat(api+web): stream topic chat live over server-sent events
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
GET /v1/topics/:name/stream opens an SSE firehose, polled server-side
every 2s and streamed as `message` events. Forward-only — clients
hit /messages once for backfill, then live from connect-time onward.
Heartbeats every 30s keep the connection through proxies.

Web chat panel reads the stream via fetch + ReadableStream so the
bearer token stays in the Authorization header (EventSource can't
set custom headers, which would force token-in-URL leaks). Auto-
reconnect with exponential backoff. setInterval polling removed.

Vercel maxDuration bumped to 300s on the catch-all API route so
streams aren't cut at the 10s default.

drizzle migrations/meta/ deleted — superseded by the filename-
tracked custom runner in apps/broker/src/migrate.ts (c2cd67a).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:02:38 +01:00
Alejandro Gutiérrez
d7cef45640 chore(release): claudemesh-cli@1.6.1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Patch release on top of 1.6.0:

- Revoke-by-id-prefix bug fix (broker.revokeApiKey now returns
  structured status; CLI surfaces not_found / not_unique). Pasting
  the 8-char prefix from `apikey list` output now works as users
  expect, instead of silently no-op'ing with a misleading "✔
  revoked" message. Already deployed to broker.
- whoami falls back to local mesh-config view when no web session
  is signed in. Users who joined via invite (and never ran
  `claudemesh login`) now see their member ids and pubkey prefixes
  per mesh, instead of a "Not signed in" dead end.
- README updated: REST surface lives at claudemesh.com/api/v1/*
  (web app), NOT ic.claudemesh.com/api/v1/* (broker). Surfaced
  during CLI-only smoke test against prod when curl on the broker
  host returned 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:50:22 +01:00
Alejandro Gutiérrez
0f32529370 fix(apikey): revoke must verify a row was actually updated
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
claudemesh apikey revoke <id> reported success even when the input
didn't match any row in mesh.api_key. The CLI's `apikey list` shows
truncated 8-char prefixes; users naturally paste those; broker did
exact-id match against meshApiKey.id; UPDATE affected 0 rows; old
revokeApiKey returned void so the CLI couldn't tell. Discovered via
end-to-end CLI smoke test against prod (roadmap validation pass).

Three-part fix:

- broker.revokeApiKey now returns
  { status: "revoked"|"not_found"|"not_unique"; id?, matches? } and
  accepts either the full id or a unique prefix (>=6 chars). Prefix
  matching is bounded to the caller's mesh and only succeeds if
  exactly one row matches; ambiguous prefixes return not_unique so
  we never silently revoke the wrong key.

- New WSApiKeyRevokeResponseMessage carries the structured status
  back to the CLI. Old apikey_revoke_ok type removed before being
  released — never shipped to users. The error path is no longer
  used for not_found/not_unique cases; the unified response carries
  both outcomes.

- CLI's apiKeyRevoke now resolves with { ok, id } | { ok: false,
  code, message }. runApiKeyRevoke surfaces the code/message and
  exits non-zero on failure (NOT_FOUND for missing, INVALID_ARGS
  for ambiguous prefix).

Net effect: pasting `claudemesh apikey revoke vq0fwjdX` now actually
revokes the key whose id starts with vq0fwjdX (or fails loud if 0
or >1 keys match). Verified against prod via the new branch's CLI
binary before commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:39:25 +01:00
Alejandro Gutiérrez
7d1538d743 docs(roadmap): correct v3.0.0 — opt-in stays, only the form changes
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Earlier wording claimed --dangerously-load-development-channels "goes
away" at v3.0.0. That overstated what we know. Some opt-in mechanism
is always required for Claude Code to accept external runtime events
from a third-party process — that's a security invariant, not a quirk
of today's flag.

What changes at v3.0.0 is the FORM of the opt-in (stable settings
entry, native transport subscription, etc.), not its existence. The
"dangerously" / "experimental" / "development" framing is what
disappears, because the underlying API graduates from experimental
to stable. The flag itself, or its successor, lives on as a normal
config entry that claudemesh install writes once.

Public roadmap and internal spec both updated to reflect this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:29:59 +01:00
Alejandro Gutiérrez
dc7e0e826d docs(roadmap): refresh after v1.6.0 ships + add daemon redesign target
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Public docs/roadmap.md gets the v1.6.0 cut moved to shipped, drops the
v0.2.0-as-next section in favor of a v1.6.x patch line + v1.7.0 demo
cut + v2.0.0 daemon redesign + v3.0.0 native-channels migration target.
Items that were in v0.2.0-next migrate down: gateways and tag routing
land in v0.3.0 alongside per-topic encryption and self-hosted broker.

The detailed strategic version lives at
.artifacts/specs/2026-05-02-roadmap.md — schedule, cost estimates,
migration paths, deliberate exclusions, the load-bearing principle for
the daemon shift ("the user is the unit, not the Claude session").
The public file stays marketing-tone; the artifact captures internal
planning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:27:50 +01:00
Alejandro Gutiérrez
2aa21fe07c fix(api): mint owner peer-identity row at mesh creation
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Web-first owners had no mesh.member row because the broker only ever
created one on first WS hello (CLI flow). The topic chat page server
component requires that row to issue a dashboard apikey
(issuedByMemberId is a FK to mesh.member), so visiting the chat for a
web-only mesh hit notFound() on the owner's own room.

Forward fix: createMyMesh now generates a fresh ed25519 peer keypair,
inserts a mesh.member row with role=admin and dashboardUserId=userId,
and subscribes the owner to the auto-created #general topic as 'lead'.
The peer secret key is intentionally discarded — web users don't sign
anything in v0.2.0 (no DMs, base64 plaintext on topics). If the same
user later runs the CLI, the broker mints a separate member row from
its own keypair; both work for their respective surfaces.

Backfill: apps/broker/scripts/backfill-owner-members.ts walks every
non-archived mesh whose owner has no member row, generates real
ed25519 keypairs via libsodium, inserts the rows in a transaction,
and subscribes each as 'lead' on #general. Already run against prod
— 13 owner rows minted, ddtest verified end-to-end via playwriter
(send → poll → render round-trip ok).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:02:40 +01:00
Alejandro Gutiérrez
6de5e275fa chore(broker): comment migrate skip flag as break-glass only
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Now that the filename-tracked runner is in place and prod is bootstrapped,
BROKER_SKIP_MIGRATE=1 is no longer needed. Removed from Coolify env;
the comment is updated to reflect that the flag is a break-glass for
ops, not the steady-state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:45:36 +01:00
Alejandro Gutiérrez
c2cd67a885 feat(broker): filename-tracked migration runner replaces drizzle's
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
drizzle's _journal.json drifted to idx=11 while the file system had 25
.sql files; the prod drizzle.__drizzle_migrations table was further
behind with 3 rows. The runtime migrator silently skipped anything
outside the journal, so every new schema change required psql -f by
hand.

The new runner tracks applied files in mesh.__cmh_migrations
(filename PK + sha256 + applied_at). On startup it bootstraps the
tracking table inline, lists migrations/*.sql lexicographically,
filters out already-applied files, and runs the rest in transaction
order under the existing pg_advisory_lock. SHA mismatches on
already-applied files emit a warning but don't fail (cosmetic edits
are common); production drift detection lives elsewhere.

Bootstrap script at apps/broker/scripts/bootstrap-cmh-migrations.ts
computes file hashes and seeds the tracking table — already run
against prod with all 25 current files registered as applied. Future
deploys pick up only truly new migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:41:51 +01:00
Alejandro Gutiérrez
4ebd138a68 fix(migrations): explicit id + enum cast for 0024 backfill
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- mesh.topic.id has no PG-side default (drizzle $defaultFn is ORM-only)
- mesh.topic_member.role needs an explicit cast to the enum type

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:34:28 +01:00
Alejandro Gutiérrez
2e97a0eeee feat(broker+api): every mesh ships with a default #general topic
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The web chat surface needed a guaranteed landing room — a topic that
exists for every mesh from creation onward so the dashboard always has
somewhere to drop the user. #general is the convention; ephemeral DMs
remain ephemeral (mesh.message_queue) so agentic privacy is unchanged.

Three hooks plus a backfill:

- packages/api/src/modules/mesh/mutations.ts — createMyMesh now calls
  ensureGeneralTopic() right after the mesh insert. New helper is
  idempotent via the unique (mesh_id, name) index.
- apps/broker/src/index.ts — handleMeshCreate (CLI claudemesh new)
  inserts #general + subscribes the owner member as 'lead' in the
  same handler.
- apps/broker/src/crypto.ts — invite-claim flow auto-subscribes the
  newly minted member to #general as 'member', defensively ensuring
  the topic exists if predates this change.
- packages/db/migrations/0024_general_topic_backfill.sql — one-shot
  backfill: creates #general for every active mesh that doesn't have
  one, subscribes every active member, and marks the mesh owner as
  'lead' based on owner_user_id == member.user_id. Idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:32:16 +01:00
Alejandro Gutiérrez
f727620d16 feat(web): topic discoverability — counts on cards + inline creation
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Two UX wins for the v0.2.0 chat surface:

- Mesh cards on /dashboard now show topic count alongside members and
  tier ("3 MEMBERS · 2 TOPICS · FREE"). Active topics render in clay,
  zero in tertiary. One aggregate query, not N+1.
- Mesh detail page replaces the CLI-hint empty state with an inline
  CreateTopicForm. Non-empty topic lists get a compact "+ new topic"
  pill in the section header. Server action validates name format
  (lowercase letters/digits/dashes, 1-50 chars), inserts via the
  unique (meshId, name) index, auto-subscribes the creator as topic
  lead, then redirects into the chat.

Sidebar audit — kept platform/manage/dev structure as is. Topics are
mesh-scoped so a top-level "topics" entry would have nothing to land
on without a mesh chosen first. Discoverability lives on the mesh
cards instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:27:19 +01:00
Alejandro Gutiérrez
c801afd2ab style(web): topic chat panel matches mesh-panel idiom
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Audit against peer-graph-panel, live-stream-panel, state-timeline-panel,
and resource-panel showed the chat used generic shadcn Card chrome
instead of the established panel pattern. Refactor swaps the wrapper
to the canonical idiom:

- rounded-[var(--cm-radius-lg)] + border-[var(--cm-border)] + bg-[var(--cm-bg)]
- mono header strip with clay-pulse fetch dot, 11px label, 10px metadata
- mono 9px footer status bar (mesh slug · poll cadence · key expiry)
- Anthropic Mono via var(--cm-font-mono) on chrome, sans on message body
- compose textarea uses cm-bg-elevated + cm-border-hover focus state
- error line in cm-fig (#c46686) instead of generic destructive

No behavior change — only chrome. Polling, send path, decode logic
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:22:22 +01:00
Alejandro Gutiérrez
b60daff886 feat(web): topic chat UI over /api/v1/* (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
New dashboard route at /dashboard/meshes/[id]/topics/[name] gives signed-in
users a thin chat client over the v0.2.0 REST surface. The mesh detail page
now lists topics with one-click links into the chat. Backend layout:

- packages/api/src/modules/mesh/api-key-auth.ts — exports
  createDashboardApiKey() that mints a 24h read+send key scoped to a single
  topic for the caller's member id. The page server component calls this on
  every render and embeds the secret in the props of the client component;
  the secret never touches sessionStorage so a tab close = key effectively
  abandoned (the row remains until expiresAt).
- apps/web/.../topics/[name]/page.tsx — server component, NextAuth gate,
  resolves the user's meshMember.id, mints the key, renders the shell.
- apps/web/src/modules/mesh/topic-chat-panel.tsx — client component, polls
  GET /v1/topics/:name/messages every 5s, sends via POST /v1/messages.
  Encoding wraps base64(plaintext) into the ciphertext field — matches the
  current broker contract until per-topic HKDF lands in v0.3.0.

The mesh detail page gains a Topics section with empty-state copy that
points users at the CLI verb (claudemesh topic create) for now; topic
creation from the web UI is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:19:38 +01:00
Alejandro Gutiérrez
7d35c779f4 chore(release): claudemesh-cli@1.6.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The v0.2.0 backend cut. Topics, API keys, REST /api/v1/*, and bridge
peers — all in one CLI release. Adds three new verb namespaces:
topic (channel pub/sub), apikey (REST client auth), bridge (cross-mesh
forwarding).

Also pins @claudemesh/sdk as a workspace devDependency so the bridge
implementation is bundled by Bun at build time and doesn't leak into
the npm tarball's runtime deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:11:56 +01:00
Alejandro Gutiérrez
f08d6c9f0c docs(handoff): 2026-05-02 — state after 1.5.0 + v0.2.0 backend
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Three pending sessions ranked by leverage: ship 1.6.0 npm release, fix migration drift, build web chat UI.
2026-05-02 15:55:53 +01:00
Alejandro Gutiérrez
9dd1e401b0 feat(sdk+cli): bridge peer — forward a topic between two meshes
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
A bridge holds memberships in two meshes and relays messages on a
single topic between them. Federation-lite without a broker-to-broker
protocol.

SDK additions:
- Bridge class (start, stop, EventEmitter for forwarded/dropped/error)
- MeshClient.joinTopic / leaveTopic / createTopic methods
- Loop prevention: plaintext hop counter prefix __cmh<n>: with maxHops
  default 2; echo guard via senderPubkey == own session pubkey

CLI additions:
- claudemesh bridge run <config.yaml> long-lived process
- claudemesh bridge init prints config template
- Zero-dep YAML parser for the flat bridge config shape

The hop prefix is visible in message bodies — minor wart, fixed in
v0.3.0 by moving loop tracking into broker primitives.

SDK kept as devDependency since Bun bundles it into dist; no impact
on npm publish or runtime resolution.

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:41:50 +01:00
Alejandro Gutiérrez
9418d0ee30 fix(api): dedupe /v1/peers by member (one row per active session)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-05-02 02:27:50 +01:00
Alejandro Gutiérrez
8b5708a604 fix(api): mount /v1 router via .route, not basePath
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-05-02 02:22:08 +01:00
Alejandro Gutiérrez
56d7cc1c48 feat(api): /v1 REST surface for external clients (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Bearer-auth REST endpoints for humans, scripts, bots — anyone without
browser-side ed25519. Same key model as broker WS, scoped by capability
and optional topic whitelist.

Endpoints (v0.2.0 minimum):
- POST /v1/messages
- GET  /v1/topics
- GET  /v1/topics/:name/messages (limit, before cursor)
- GET  /v1/peers

Auth: Authorization: Bearer cm_<secret>. Middleware verifies prefix +
SHA-256 hash with constant-time compare; capability + topic-scope
asserted per route. Cross-mesh isolation: every endpoint scopes to
apiKey.meshId.

Live delivery: writes to messageQueue + topic_message; broker's
existing pendingTimer drains and pushes to live peers. Real-time
push from REST writes is a follow-up.

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:19:12 +01:00
Alejandro Gutiérrez
13d691980a feat(broker+cli): apikey create/list/revoke verbs (v0.2.0 #71)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Issuance flow over WS for now (REST endpoints come next slice).
Plaintext secret returned ONCE on create — never recoverable.

- broker: 3 WS handlers (apikey_create/list/revoke), wire types in
  union, audit log on issuance + revoke
- ws-client: apiKeyCreate/List/Revoke with resolver maps, response
  dispatch
- CLI: claudemesh apikey create <label> [--cap a,b] [--topic c,d]
  [--expires ISO]; list shows status, scope, last-used; revoke by id
- policy: apikey create + revoke prompt by default (issuing or
  disabling a credential is meaningful)

Default capability set is "send,read" — least privilege for unscoped
keys (admin must explicitly opt-in).

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:13:12 +01:00
Alejandro Gutiérrez
f45380d231 feat(broker): api key schema and helpers
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Foundation for v0.2.0 REST + external WS auth.

Bearer tokens stored as SHA-256 hashes; secrets are 256-bit CSPRNG so
Argon2 would waste cost without security gain.

Adds mesh.api_key table, migration 0023 applied manually to prod, and
helpers: createApiKey, listApiKeys, revokeApiKey, verifyApiKey.

Next slices: CLI apikey verbs and REST endpoints in apps/web router.

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:09:44 +01:00
Alejandro Gutiérrez
f71218c1e1 docs(spec): v0.2.0 — humans-in-mesh interface is REST, not browser WS
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Broker already plumbs peer_type. Real blocker is browser-side ed25519
hello-sig — sidestepped by exposing REST API for humans (and external
scripts/bots), with web chat UI as a thin REST client using dashboard
session auth. Collapses #2 (humans) and #3 (REST) into one deliverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:06:29 +01:00
Alejandro Gutiérrez
f98c2de5a3 fix(broker): topic-tagged sends bypass direct-target pre-flight
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
handleSend's pre-flight check rejected #<topicId> sends because the
target wasn't matched by @group / * / pubkey, so it fell into the
"direct" branch and looked for a peer with that pubkey. Topic targets
need their own class — delivery happens via topic_member, not by
matching connected peers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:01:35 +01:00
Alejandro Gutiérrez
1afae7a507 feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Adds the third axis of mesh organization: mesh = trust boundary,
group = identity tag, topic = conversation scope. Topic-tagged
messages filter delivery by topic_member rows and persist to a
topic_message history table for back-scroll on reconnect.

Schema (additive):
- mesh.topic, mesh.topic_member, mesh.topic_message tables
- topic_visibility (public|private|dm) and topic_member_role
  (lead|member|observer) enums
- migration 0022_topics.sql, hand-written following project convention
  (drizzle journal has been drifting since 0011)

Broker:
- 10 helpers (createTopic, listTopics, findTopicByName, joinTopic,
  leaveTopic, topicMembers, getMemberTopicIds, appendTopicMessage,
  topicHistory, markTopicRead)
- drainForMember matches "#<topicId>" target_specs via member's
  topic memberships
- 7 WS handlers (topic_create/list/join/leave/members/history/mark_read)
  + resolveTopicId helper accepting id-or-name
- handleSend auto-persists topic-tagged messages to history

CLI:
- claudemesh topic create/list/join/leave/members/history/read
- claudemesh send "#deploys" "..." resolves topic name to id
- bundled skill teaches Claude the DM/group/topic decision matrix
- policy-classify recognizes topic create/join/leave as writes

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:53:42 +01:00
Alejandro Gutiérrez
b4f457fceb feat(cli): 1.5.0 — CLI-first architecture, tool-less MCP, policy engine
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CLI becomes the API; MCP becomes a tool-less push-pipe. Bundle -42%
(250 KB → 146 KB) after stripping ~1700 lines of dead tool handlers.

- Tool-less MCP: tools/list returns []. Inbound peer messages still
  arrive as experimental.claude/channel notifications mid-turn.
- Resource-noun-verb CLI: peer list, message send, memory recall, etc.
  Legacy flat verbs (peers, send, remember) remain as aliases.
- Bundled claudemesh skill auto-installed by `claudemesh install` —
  sole CLI-discoverability surface for Claude.
- Unix-socket bridge: CLI invocations dial the push-pipe's warm WS
  (~220 ms warm vs ~600 ms cold).
- --mesh <slug> flag: connect a session to multiple meshes.
- Policy engine: every broker-touching verb runs through a YAML gate
  at ~/.claudemesh/policy.yaml (auto-created). Destructive verbs
  prompt; non-TTY auto-denies. Audit log at ~/.claudemesh/audit.log.
- --approval-mode plan|read-only|write|yolo + --policy <path>.

Spec: .artifacts/specs/2026-05-02-architecture-north-star.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:18:19 +01:00
Alejandro Gutiérrez
ff551ccf3d chore(cli): release 1.0.1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Ships disconnect/kick/ban three-tier peer removal, revoked-hello
friendly error with 'contact mesh owner' message, WS close codes
4001 (kicked) and 4002 (banned) that stop CLI auto-reconnect.

latest + alpha dist-tags both → 1.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:59:52 +01:00
Alejandro Gutiérrez
b49e9a9b61 feat(cli+broker): three-tier peer removal: disconnect, kick, ban
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Broker (apps/broker/src/index.ts)
- Unified disconnect/kick handler uses close code 1000 for disconnect
  (CLI auto-reconnects) vs 4001 for kick (CLI exits, no reconnect).
- Ban now closes with code 4002.
- Hello handler: revoked members get a specific 'revoked' error with a
  'Contact the mesh owner to rejoin' message, then ws.close(4002).
  Previously banned users saw the generic 'unauthorized' error.
- list_bans handler returns { name, pubkey, revokedAt } for each
  revoked member.

CLI (apps/cli)
- ws-client: close codes 4001 and 4002 set .closed = true and stash
  .terminalClose so callers can surface a friendly message instead of
  the low-level 'ws terminal close' error. Revoked error in hello is
  also captured as a terminal close.
- withMesh catches terminalClose and prints:
  4001 → 'Kicked from this mesh. Run claudemesh to rejoin.'
  4002 → the broker's 'Contact the mesh owner to rejoin.' message
- kick.ts now exports runDisconnect + runKick with clear hints:
  'disconnect' → 'They will auto-reconnect within seconds.'
  'kick'       → 'They can rejoin anytime by running claudemesh.'
- cli.ts adds 'disconnect' dispatch; HELP updated.

Semantics:
  disconnect: session reset, no DB state, auto-reconnects
  kick      : session ends, no DB state, user must manually rejoin
  ban       : session ends + revokedAt set, cannot rejoin until unban

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:55:05 +01:00
Alejandro Gutiérrez
163e1be70a chore(cli): release 1.0.0 — out of alpha
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Promote CLI from 1.0.0-alpha.42 to stable 1.0.0 so
`npm i -g claudemesh-cli` installs the current release without
needing the @alpha dist-tag.

Both dist-tags now point at 1.0.0 — `@alpha` kept as an alias for
continuity so existing docs, install scripts, and scheduled upgrade
commands keep working.

upgrade + doctor commands updated to prefer the `latest` dist-tag
(falling back to `alpha`) and to suggest `npm i -g claudemesh-cli`
without the @alpha suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:06:11 +01:00
Alejandro Gutiérrez
3d2ab0cb4b fix(cli): production-grade peer disambiguation (alpha.42)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Three bugs compounding when multiple peers share a display name:

1. list_peers (MCP + CLI) truncated pubkey to 12 hex chars with an
   ellipsis. A truncated pubkey cannot be used as a routing key, so
   the caller had no way to disambiguate visually.

2. send_message required the full 64-hex pubkey and refused prefix
   input, forcing callers to rely on --json output to get a full key.

3. Name-based resolution returned the first exact match without
   filtering the caller's own session — so "send to <my-own-name>"
   would bounce against the broker's self-send guard when another
   session of the same user was the intended target.

Fixes:
- list_peers now prints 16-char pubkey prefix labelled "pubkey: …"
  (MCP) and appends it to CLI output
- send_message accepts any 8–64 hex-char prefix and resolves against
  live peer lists across joined meshes; unique match routes, multi-
  match returns a disambiguation error listing each candidate's
  displayName + pubkey + cwd
- Name matches now skip the caller's own session pubkey; multiple
  same-named matches fail loudly with a copy-pasteable pubkey
  disambiguation hint instead of silently picking one
- Full 64-char pubkeys without a live match still queue at the
  broker (preserves offline-delivery semantics)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:56:41 +01:00
Alejandro Gutiérrez
0664180a54 feat(web): universe dashboard — meshes + incoming invitations
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
New /dashboard landing that surfaces meshes and invitations-to-you
in one view. Replaces the simple mesh grid at /dashboard (preserved
at /dashboard/legacy).

Backend additions:
- GET /api/my/invites/incoming — pending_invite rows addressed to
  the authed user's email, joined with invite for role + expiry and
  user/mesh for display. Unaccepted + unrevoked + unexpired only.
- DELETE /api/my/invites/incoming/:id — dismiss a pending invite
  (revokes the pending_invite row only; underlying invite code stays
  valid so the inviter can re-send).

Web additions (all under apps/web/src/modules/dashboard/universe/):
- welcome.tsx — editorial serif header with mesh + invite counts
- invitations.tsx — client card with Accept (→ /i/:code claim flow)
  and optimistic Decline
- meshes-grid.tsx — hero card + compact grid, linked to mesh detail
- reveal.tsx — fade-up motion matching marketing _reveal.tsx

Styling uses the existing claudemesh design tokens (--cm-clay,
--cm-bg-elevated, Anthropic Sans/Serif/Mono) — nothing redefined.

Onboarding redirect (0 meshes → /meshes/new?onboarding=1) preserved,
now gated on 0 invitations too so users with pending invites still
land on the dashboard.

Sidebar icon switched to Atom for the "universe" concept.

Standalone prototype saved at prototypes/live-dashboard.html for
reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:31:15 +01:00
Alejandro Gutiérrez
2abf86d540 fix(cli): short-circuit join <slug> when already a member (alpha.41)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
If the arg isn't a URL and matches a mesh already in local config,
print a hint pointing at `launch --mesh <slug>` instead of treating
the slug as an invite code. Avoids the 501 invite_v2_disabled confusion
when users try to "enter" a mesh they already own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:11:46 +01:00
Alejandro Gutiérrez
a5347cebc0 fix(cli): silence "session restored" log for one-shot commands (alpha.40)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Add quiet opt to BrokerClient; withMesh passes quiet:true so commands
like peers/state/info/remind no longer print per-mesh restore chatter.
Long-running paths (launch, MCP) stay verbose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:54:53 +01:00
Alejandro Gutiérrez
622ea569ad fix(cli): filter self from claudemesh peers output (alpha.39)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The peers command opens its own WS to each mesh, which briefly appears
as a hostname-PID peer. Filter it out by session pubkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:50:18 +01:00
Alejandro Gutiérrez
d7f381a1e8 fix(cli): surface broker error messages in ban/unban (alpha.38 fix)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:57:08 +01:00
Alejandro Gutiérrez
3ceac68e67 feat(cli+broker): kick, ban, unban, bans commands
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Broker WS handlers:
- kick: disconnect peer(s) by name, --stale duration, or --all.
  Authz: owner or admin only. Closes WS + marks presence disconnected.
- ban: kick + set revokedAt on mesh.member. Hello already rejects
  revoked members, so ban is instant and permanent until unban.
- unban: clear revokedAt. Peer can rejoin with their existing keypair.
- list_bans: return all revoked members for a mesh.

Session-id dedup (previous commit): handleHello disconnects ghost
presences with matching (meshId, sessionId) before inserting the new
one. Eliminates duplicate entries after broker restarts.

CLI (alpha.37):
- claudemesh kick <peer|--stale 30m|--all>
- claudemesh ban/unban <peer>
- claudemesh bans [--json]
- Uses new sendAndWait() on ws-client for request-response pattern
  over WS (generic _reqId resolver).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:37:38 +01:00
Alejandro Gutiérrez
5ddb11b2d5 fix(broker): dedup presences by session_id on hello
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
When a client reconnects with the same session_id before the 90s
stale sweeper runs, the old ghost presence stays in the connections
map. Result: duplicate entries in list_peers for the same Claude
Code instance.

Now: handleHello iterates connections for matching (meshId, sessionId),
closes the old WS, deletes from map, marks disconnected in DB.
One session_id = one presence, always.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:40:25 +01:00
Alejandro Gutiérrez
2edbfce7d3 fix(broker): add BROKER_SKIP_MIGRATE=1 escape hatch for manual-migrated DBs
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:59:28 +01:00
Alejandro Gutiérrez
9f3a82dd63 fix(broker): use sql.unsafe for SET lock_timeout in migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:55:04 +01:00
Alejandro Gutiérrez
05729ad8a4 feat(ga): close remaining GA blockers (backcompat, HA prep, tests, docs)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Backwards compat shim (task 27)
- requireCliAuth() falls back to body.user_id when BROKER_LEGACY_AUTH=1
  and no bearer present. Sets Deprecation + Warning headers + bumps a
  broker_legacy_auth_hits_total metric so operators can watch the
  legacy traffic drain to 0 before removing the shim.
- All handlers parse body BEFORE requireCliAuth so the fallback can
  read user_id out of it.

HA readiness (task 29)
- .artifacts/specs/2026-04-15-broker-ha-statelessness-audit.md
  documents every in-memory symbol and rollout plan (phase 0-4).
- packaging/docker-compose.ha-local.yml spins up 2 broker replicas
  behind Traefik sticky sessions for local smoke testing.
- apps/broker/src/audit.ts now wraps writes in a transaction that
  takes pg_advisory_xact_lock(meshId) and re-reads the tail hash
  inside the txn. Concurrent broker replicas can no longer fork the
  audit chain.

Deploy gate (task 30)
- /health stays permissive (200 even on transient DB blips) so
  Docker doesn't kill the container on a glitch.
- New /health/ready checks DB + optional EXPECTED_MIGRATION pin,
  returns 503 if either fails. External deploy gate can poll this
  and refuse to promote a broken deploy.

Metrics dashboard (task 32)
- packaging/grafana/claudemesh-broker.json: ready-to-import Grafana
  dashboard covering active conns, queue depth, routed/rejected
  rates, grant drops, legacy-auth hits, conn rejects.

Tests (task 28)
- audit-canonical.test.ts (4 tests) pins canonical JSON semantics.
- grants-enforcement.test.ts (6 tests) covers the member-then-
  session-pubkey lookup with default/explicit/blocked branches.

Docs (task 34)
- docs/env-vars.md catalogues every env var the broker + CLI read.

Crypto review prep (task 35)
- .artifacts/specs/2026-04-15-crypto-review-packet.md: reviewer
  brief, threat model, scope, test coverage list, deliverables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:51:28 +01:00
Alejandro Gutiérrez
49e0af0fc0 chore(cli): bump to alpha.36 with security fixes
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-15 19:18:57 +01:00
Alejandro Gutiérrez
2be5e9dccb fix(security): resolve all 17 codex findings — auth, grants, crypto, ops
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Critical: broker HTTP auth via cli_session bearer token on all /cli/*;
file download requires auth+membership; v2 claim gated; duplicate
claimInviteV2Core removed; grant enforcement tries member then
session pubkey; audit hash uses canonical sorted-keys JSON.

High: rate limit args fixed (burst 10, 60/min) + both buckets swept;
BROKER_ENCRYPTION_KEY fail-fast in prod; migrate uses pg_try + lock_
timeout; hello validates sessionPubkey hex; blocked DMs rejected pre-
queue; watch timers cleaned on disconnect.

Medium: inbound pushes serialized; reconnect jitter + timer guard;
hardcoded URLs through env; v2 claim path configurable.

Low: WSHelloMessage optional protocolVersion+capabilities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:18:25 +01:00
Alejandro Gutiérrez
1a7a059e75 fix: queue TTL + per-member send rate limit + size cap + no-recipient reject + ack.error
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Broker (all need redeploy):
- sweepOrphanMessages: DELETE undelivered message_queue rows older
  than 7 days; hourly sweep. Stops unbounded growth when a sender
  typos a name (queued forever, never claimed).
- Per-member send rate limit: TokenBucket(60/min, burst 10) keyed on
  memberId so reconnecting can't bypass. Surfaces as queued=false,
  error='rate_limit: ...'.
- Pre-flight size cap: reject at handleSend if nonce+ciphertext+
  targetSpec exceeds env.MAX_MESSAGE_BYTES with a clear error
  instead of silent WSS frame-level kill.
- No-recipient reject: for direct sends, check any matching peer
  is connected BEFORE queueing. Kills the self-send silent drop
  (sending to your own pubkey when you only have one session
  connected) and typo-to-offline-peer silent drops.
- WSAckMessage.error field added for structured failure reasons.

CLI:
- ws-client ack handler reads msg.queued and msg.error; surfaces
  rate_limit / too_large / no_recipient to callers instead of
  returning ok:true with a dummy messageId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:44:09 +01:00
Alejandro Gutiérrez
39fe296aaa fix(cli): decrypt falls back to member secret key when session key fails
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
When Alice's session-A encrypts a direct message to Bob (target = Bob's
stable member pubkey) and Bob's session-B receives it, Bob has BOTH an
ephemeral session secret key and the member secret key. The old code
only tried session_sk, then silently failed with '⚠ message from
<sender> failed to decrypt' even though the message was valid —
just encrypted to the member key.

Now: try session first, fall back to member on null. Matches the
sender side's choice freedom (encrypt using either key).

Repros when: user opens multiple Claude Code sessions (all use the
same member key but each generates its own session key), and one
session sends to another by display-name resolution which returns
the member pubkey.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:37:36 +01:00
Alejandro Gutiérrez
3dfab0f792 fix(broker): don't broadcast peer_joined/peer_left/peer_returned to same-pubkey sessions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
When a user opens multiple Claude Code instances on one laptop they
all share the same memberPubkey (one identity, one config.json). The
broker was broadcasting each Claude Code start/stop to every OTHER
session of the same user — showing as 'peer agutierrez left / joined'
spam in every active claude terminal.

Now: skip broadcast to presences whose memberPubkey equals the joining
or leaving presence's memberPubkey. Other actual peers on the mesh
still see the event.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:28:57 +01:00
Alejandro Gutiérrez
6f4a44e281 fix(db): realign audit_log schema — actor_member_id, prev_hash, hash chain
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The broker code moved to an append-only hash-chained audit log
(actor_member_id / actor_display_name / payload / prev_hash / hash
with integer GENERATED ALWAYS AS IDENTITY id) but prod still had
the original 0000-migration shape (actor_peer_id / metadata /
text id). Every peer_joined / peer_left event logged 'audit log
insert failed' — no audit trail captured at all.

Applied manually on prod already; committing the migration so
future environments converge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:26:48 +01:00
Alejandro Gutiérrez
4bc3c045ae fix(cli): send_message hard-fails on unknown peer name; dedup-annotate list_peers
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Two bugs that combined to make Claude's peer-send look successful even
when the recipient didn't exist:

1. resolveClient fell through to 'let the broker try' when a single
   mesh was joined and the name didn't match any peer. The broker
   queued the message against the literal unknown string, matched no
   peer in fan-out, but returned a messageId — so the CLI reported
   '✓ lezg → msgId' for a peer that was never there.

   Now: refuse to send, list the known peer names.

2. list_peers showed the same pubkey multiple times with different
   display_names (one per live session) without hinting that they
   were the same member — so Claude treated them as distinct people.

   Now: annotate with '[shares key with N other session(s)]' so the
   caller understands one pubkey = one identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:10:47 +01:00
Alejandro Gutiérrez
94e914f476 fix(broker): reject mesh create without valid pubkey
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Older CLIs sometimes called POST /cli/mesh/create without a pubkey,
and the broker stored the string 'pending' as peer_pubkey on the
owner's mesh.member row. Every subsequent hello from the real CLI
failed the membership lookup silently, leaving the connection in
'reconnecting' forever with no useful log line.

Now: validate pubkey is 64 hex chars before creating the owner
member row. Existing 'pending' rows on prod were patched manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:50:11 +01:00
Alejandro Gutiérrez
1bb702e481 chore(cli): bump to alpha.32 2026-04-15 08:54:26 +01:00
Alejandro Gutiérrez
45d85f5eaa chore: wrap up the gap-closing session
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- info/inbox commands → unified render.ts
- install route: drop in-memory counter, rely on PostHog + structured logs
- docs: roadmap, CLAUDE.md reflect alpha.31 state
- tests workflow now also builds + smoke-tests the CLI bundle
- homebrew tap bootstrap kit in packaging/homebrew-tap-bootstrap/
  (README + copy of the formula template for dropping into the tap repo)
- upstream Claude Code issue draft for rich <channel> UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:53:59 +01:00
Alejandro Gutiérrez
ee12510ef1 refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:44:52 +01:00
Alejandro Gutiérrez
c9ede3d469 fix(ci): pass --define for version to bun build --compile
The compile step bypasses build.ts, so the define had to be added
to the workflow's bun build command directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:41 +01:00
Alejandro Gutiérrez
b998e35d17 fix(cli): auto-inject VERSION from package.json at build time
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
alpha.28-30 binaries all reported 'v1.0.0-alpha.27' from a hardcoded
constant in src/constants/urls.ts — my bump sed only matched
package.json's 'version' key, not the TypeScript literal.

build.ts now reads package.json version and injects it via Bun's
`define` (source-text replacement, equivalent to esbuild --define).
urls.ts reads the injected symbol with a runtime fallback for `bun
src/...` dev mode. Version drift can't recur.

+ peers + status migrated to the render.ts unified renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:06:13 +01:00
Alejandro Gutiérrez
506c470441 docs: ship-all retrospective — 14/15 items, 97% addressed
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Final scoreboard against the Claude Code-grade CLI bar. Captures
every file shipped, every gotcha hit, and the one remaining item
(rich channel UI) that needs upstream Claude Code work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:56:28 +01:00
Alejandro Gutiérrez
b4703a482d feat(cli): bump to alpha.30 + channel message polish
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Channel messages now render as '<sender>: <body>' with priority
+ broadcast badges in Claude Code's <channel> reminders, so the inbox
reads as a chat thread rather than bare lines.

[URGENT] alice: deploy is blocking release
bob (broadcast): team sync 15min
charlie: pr #42 lgtm

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:54:53 +01:00
Alejandro Gutiérrez
29f546abcf fix(ci): skip smoke tests for cross-compiled binaries
macos-latest = ARM64, ubuntu-latest = x64. Only darwin-arm64 and
linux-x64 binaries can execute on their build host; the others are
cross-compiled and will Exec format error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:51:25 +01:00
Alejandro Gutiérrez
5716a6ce22 chore: refresh pnpm-lock for cli-v2 qrcode-terminal dep
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:47:52 +01:00
Alejandro Gutiérrez
d37516213a chore(cli-v2): un-ignore CLI source tree for binary release workflow
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The CLI source (242 files, ~14k lines) was gitignored during the
earlier cli→cli-v2 reorg so only the published npm package carried it.
That blocks the GitHub Actions release workflow (release-cli.yml),
which clones the repo fresh on each runner and needs the source to
compile binaries via `bun build --compile`.

Moves the gitignore from root-level to `apps/cli-v2/.gitignore` with
only the usual build artefacts excluded (node_modules, dist, .turbo,
.cache). Source is now in git at apps/cli-v2/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:45:44 +01:00
Alejandro Gutiérrez
5b69de08da fix(ci): use packageManager from package.json for pnpm setup
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:43:50 +01:00
Alejandro Gutiérrez
ccf95ff382 feat(distribution): binary release pipeline + brew + winget
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- .github/workflows/release-cli.yml: build self-contained binaries via
  `bun build --compile` for darwin/linux/windows × x64/arm64 on every
  cli-v* tag, attach to GitHub Release with SHA256SUMS, auto-bump the
  homebrew tap on non-prerelease versions.
- packaging/homebrew/claudemesh.rb.template: formula template for the
  homebrew-claudemesh tap.
- packaging/winget/claudemesh.yaml.template: winget manifest template.
- /install script now detects absence of Node and downloads the
  platform-appropriate binary from the GitHub Release, installs to
  ~/.claudemesh/bin, and shims into ~/.local/bin — zero Node required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:42:16 +01:00
Alejandro Gutiérrez
43f2728283 docs: specs for binary distribution pipeline + per-peer capabilities
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Capture the design for the two tier-2 items that weren't shipped inline
in alpha.28 — both require CI/infrastructure work (GitHub Actions,
Homebrew tap, winget manifest) or broker schema migration that's safer
to do as a separate PR with feature flag rollout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:34:45 +01:00
Alejandro Gutiérrez
d33b8fc43b feat(web): install.sh and InstallToggle use one-command UX
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- /install shell script now points users at `claudemesh <invite-url>`
  (one step) instead of the split join+launch
- InstallToggle first-time panel shows single copy-block with
  install+launch on the same line
- Also advertises url-handler install and shell completions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:33:36 +01:00
Alejandro Gutiérrez
ce52fcef2d feat(invite): branded email + one-command install+launch UX
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Email (broker):
- Rebrand mesh-invitation.tsx to match site (clay accent #d97757,
  cream fg, Anthropic Serif/Mono, dark bg). Mesh glyph in header.
- Hero CTA links to the /i/short URL landing page.
- Single one-liner 'npm i -g claudemesh-cli && claudemesh launch --join URL'
  so new users copy once, paste once, done.

Web InstallToggle:
- Replace two-step numbered list with single one-liner in the first-time
  panel. Reduces copy/paste ops from 2 to 1 and stops prescribing
  'YourName' as a literal (CLI now defaults to $USER).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:14:27 +01:00
Alejandro Gutiérrez
77ee1d0d80 feat(broker): branded react-email template for mesh invite
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Replaces the plain-text invite email with a standalone react-email
template (apps/broker/src/emails/mesh-invitation.tsx) using
@react-email/components + Tailwind. Rendered on demand in
handleCliMeshInvite and sent as both HtmlBody and TextBody via
Postmark (or html+text via Resend).

Self-contained — no dependency on @turbostarter/email, i18n, or ui
packages. Adds react, react-dom, @react-email/components, @react-email/render
to broker deps. Enables tsconfig jsx: react-jsx and .tsx includes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:04:28 +01:00
Alejandro Gutiérrez
2f27a5eef4 feat(broker): actually send invite email via Postmark, return emailed flag
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Broker now sends the invite email when body.email is provided and
POSTMARK_API_KEY (or RESEND_API_KEY) is configured. Returns
`emailed: boolean` so the CLI can honestly report whether the email
was sent instead of falsely claiming success on link generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:48:53 +01:00
Alejandro Gutiérrez
32851419e6 fix(broker): generate owner keys on CLI mesh create + proper invite signing
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
handleCliMeshCreate now generates ownerPubkey/ownerSecretKey/rootKey so
CLI-created meshes can issue invites. handleCliMeshInvite builds the
full signed v1 payload + v2 capability (matching createMyInvite in
packages/api) and self-heals meshes created by older broker versions
that are missing keys.

Fixes 500 on claudemesh share after CLI mesh create.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:37:16 +01:00
Alejandro Gutiérrez
e2b6e53cc1 feat(broker): add POST /cli/mesh/:slug/invite endpoint
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:10:34 +01:00
Alejandro Gutiérrez
3595fc2c4d feat(broker): add list_services and list_commands tools to telegram AI
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:20:00 +01:00
Alejandro Gutiérrez
2825ef7151 feat(broker): add conversation memory to telegram AI (10-turn window)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:09:32 +01:00
Alejandro Gutiérrez
a9858ef876 fix(broker): teach AI difference between mesh names and peer names
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:06:08 +01:00
Alejandro Gutiérrez
6836a495a4 fix(broker): switch telegram AI to HTML formatting + strip markdown
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:58:45 +01:00
Alejandro Gutiérrez
07720f8f1e feat(broker): add list_meshes tool + multilingual AI responses
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:53:03 +01:00
Alejandro Gutiérrez
f4881b21b0 feat(broker): add claude-powered telegram bot with tool calling
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:40:16 +01:00
Alejandro Gutiérrez
4561076904 fix(broker): accept pubkey in mesh create + use in member row
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:02:09 +01:00
Alejandro Gutiérrez
0d53f2ae52 fix(broker): use raw SQL for mesh create to avoid Drizzle default issues
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:57:54 +01:00
Alejandro Gutiérrez
b328e78bd3 fix(broker): import generateId for mesh create handler
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:47:35 +01:00
Alejandro Gutiérrez
23604a125e fix(broker): mesh list includes owner meshes + auto-increment slug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:12:06 +01:00
Alejandro Gutiérrez
b680260c8d feat(broker): add POST /cli/mesh/create endpoint
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:04:41 +01:00
Alejandro Gutiérrez
b65a545ece feat(broker): add /cli/meshes endpoint for merged mesh list
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:39:08 +01:00
Alejandro Gutiérrez
d07cff788c feat: three-token auth flow (session_id + user_code + device_code)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- session_id (clm_sess_...) in browser URL — identifies login attempt
- user_code (ABCD-EFGH) visual confirmation — shown in both terminal and browser
- device_code (secret) — CLI polls with this, never displayed
- CLI accepts stdin paste of JWT token while polling (race)
- Web page handles both ?session= and ?code= params

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:19:08 +01:00
Alejandro Gutiérrez
bb1310167e feat: granular mesh permissions + mesh delete + share picker
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Drizzle schema: mesh.permission table with 11 boolean flags
- Default permissions by role (owner > admin > member)
- Broker: GET/POST /cli/mesh/:slug/permissions
- Broker: DELETE /cli/mesh/:slug (owner only, soft delete)
- Broker: permission check module (getPermissions, checkPermission, setPermissions)
- CLI: mesh share with interactive mesh picker
- CLI: mesh delete with server-side delete + confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:03:28 +01:00
Alejandro Gutiérrez
ea4e3b03bb feat: paste-token auth flow for CLI
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
- Broker: POST /cli/token generates a 30-day JWT
- Web: /token page with Generate + Copy button
- Web: /api/auth/cli/token proxies to broker
- CLI: login option 3 "Paste a token" for headless environments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:17:38 +01:00
Alejandro Gutiérrez
1a42c2ef09 chore: trigger Vercel redeploy
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:58:17 +01:00
Alejandro Gutiérrez
43b70013c5 fix: exclude cli-v2 from git to unblock Vercel builds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:53:29 +01:00
Alejandro Gutiérrez
b8d8b5469b fix: rename cli-v2 package to avoid Turborepo duplicate workspace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:46:18 +01:00
Alejandro Gutiérrez
ab7fb6bd31 chore(web): bust Vercel build cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:39:04 +01:00
Alejandro Gutiérrez
b2999878c4 fix(web): inline CSS stub loader for Vercel path resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:34:56 +01:00
Alejandro Gutiérrez
a890a1d92e fix(web): use --import instead of --experimental-loader for Vercel compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:29:52 +01:00
Alejandro Gutiérrez
80a6b8b50f fix(web): resolve Payload CMS build error with Node.js ESM loader
Payload CMS imports .css/.scss/.svg files that Node.js ESM can't handle
during page data collection. Added a custom ESM loader that stubs these
asset imports, fixing the build that has been broken since the upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:24:32 +01:00
Alejandro Gutiérrez
465ff9a10e fix(web): rewrite CLI auth login as standalone component
Remove dependency on SocialProviders/RegisterForm which need
React Query providers. Self-contained with authClient directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:06:42 +01:00
Alejandro Gutiérrez
0f46c787a7 feat(web): show authenticated user in marketing header
Header now checks session and shows avatar + name + Dashboard link
when logged in, instead of always showing Sign in / Start free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:55:33 +01:00
Alejandro Gutiérrez
a365fef170 feat(web): dedicated CLI auth page with inline login/register
No more redirect to generic /auth/login. The /cli-auth?code=XXXX page
now shows auth forms inline (Google, GitHub, email) with device code
context — like Anthropic's "Build with Claude" page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:51:18 +01:00
Alejandro Gutiérrez
ca441dae45 feat(broker): device-code auth with PostgreSQL persistence
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Drizzle schema: device_code + cli_session tables in mesh pgSchema
- Broker endpoints: POST /cli/device-code, GET /cli/device-code/:code,
  POST /cli/device-code/:code/approve, GET /cli/sessions
- Web app API routes now proxy to broker (no in-memory state)
- Tracks devices per user: hostname, platform, arch, last_seen, token_hash
- JWT signed with CLI_SYNC_SECRET, 30-day expiry
- Session revocation support via revokedAt column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:22:13 +01:00
Alejandro Gutiérrez
ac709dbe92 feat(web): add device-code OAuth API for CLI authentication
New API endpoints:
- POST /api/auth/cli/device-code/new — issue device code + user code
- GET /api/auth/cli/device-code/[code] — poll device code status
- POST /api/auth/cli/device-code/[code]/approve — approve by device code
- POST /api/auth/cli/device-code/approve-by-user-code — approve by user code

Updated cli-auth page to auto-approve on page load after authentication
(no manual "Approve" button click needed).

Enables `claudemesh login` and `claudemesh register` CLI commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:10:09 +01:00
Alejandro Gutiérrez
d0fbc64e7e feat(web): two-mode pricing (hosted + self-hosted) across landing
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Rewrites pricing section from single "public beta" card to side-by-side
hosted vs self-hosted comparison reflecting the cleaner product
architecture. Enterprise sell is now concrete: "Run our Docker image,
point your CLI at it, done — your mesh never leaves your VPC."

Updates hero subtitle, CTA, FAQ, and where-mesh-fits claim card to
reinforce the two deployment modes consistently across the landing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:17:38 +01:00
Alejandro Gutiérrez
f1d35b10da fix(cli): clean TTY handoff to claude via spawnSync + defensive reset
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Terminals spawned by `claudemesh launch` were dropping keystrokes at
claude's prompt and showing the launch wizard re-rendering on top of
claude's TUI. Two compounding causes:

1. spawn() + child.on('exit') kept the parent node event loop alive
   during claude's lifetime. Any stray readline 'data' listener or
   late render from the wizard could fire on the inherited stdin/
   stdout, stealing keystrokes or painting over claude's Ink TUI.
2. Raw mode / alt-screen / hidden cursor set by the wizard helpers
   was not reliably restored before the handoff.

Fix:
- Swap spawn for spawnSync so the parent event loop is fully blocked
  while claude runs. No listener or setImmediate can fire during
  claude's lifetime.
- Hard TTY reset right before the spawn: setRawMode(false),
  removeAllListeners on stdin, show cursor (ESC[?25h), exit alt
  screen (ESC[?1049l). Defensive — survives partial wizard cleanup.
- Move cleanup() registration to process.on('exit') so it runs
  synchronously on every exit path (normal, signal, throw).
- Preserve signal forwarding: if claude dies from a signal, re-raise
  the same signal on the parent so exit codes propagate correctly.

Bumps to v0.10.6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:38:09 +01:00
Alejandro Gutiérrez
5e97d48cd5 feat(web): animated mesh hero with peer constellation + comparison section
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- New hero section with a live animated mesh background: three equal
  Claude Code peers in a triangle layout + six desaturated background
  peers, all rendered pixel-perfect from pure React/CSS using the exact
  Unicode characters and colors from Claude Code's own source.
- User prompts type into the bottom prompt-input box and "submit" to
  scrollback (matching real Claude Code behavior). Mesh sends fly as
  envelope icons with fading trails between peers; receivers pulse on
  arrival. Dynamic routing by peer displayName.
- Radial vignette overlay keeps the hero title crisp while letting the
  corner peers pulse visibly around the edges. Top/bottom linear fades
  bleed into adjacent sections.
- Responsive scaling via ResizeObserver: cover-fit in hero bg context,
  contain-fit for standalone use.
- Features section: added Skills, MCPs, and Commands as the first
  three tabs — the mesh's real differentiators. Updated subtitle copy.
- New "Where claudemesh fits" section positioned between Features and
  WhatIsClaudemesh: four-card comparison (vs MCP, vs subagents,
  vs OpenClaw, and the positive claim) framing claudemesh as a wire
  between Claude Code sessions, not a replacement for any of them.

All work is additive: 10 new files in apps/web/src/modules/marketing/
home/fake-claude-code/ plus hero-mesh-animation.tsx, hero-with-mesh.tsx,
and where-mesh-fits.tsx. Single edit each to features.tsx and
(marketing)/page.tsx to swap in the new hero and mount the new section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:39:24 +01:00
Alejandro Gutiérrez
c8ae6462e3 feat(web): email invite mode + ic:// removal in invite generator (wave 3)
Completes the v2 invite user experience. The generator now ships two
delivery modes behind a simple Link | Email toggle, and the vestigial
ic:// scheme is gone from every user-visible surface.

Modes
- Link (default, existing flow): mints a v2 invite, displays short URL
  + QR + CLI command. No behavioral change vs wave 2.
- Email (new): admin types a recipient email, submit dispatches through
  the POST /api/my/meshes/:id/invites/email endpoint (wave 2), which
  mints a normal v2 invite, records a pending_invite row, and stubs the
  Postmark send with a TODO. Result card shows a "✓ Invite sent to X"
  banner plus the same QR card so the admin can also share manually.

Honest UX copy on the stub:
"Email delivery is stubbed in v0.1.x — the invite is valid. Share the
link directly if needed." Avoids pretending something shipped that
hasn't.

ic:// cleanup
- inviteLink field no longer rendered or stored (still returned by the
  API for backward compatibility; just not surfaced)
- CLI command now copies `claudemesh join <code>` (falls back to
  shortUrl when code is null), matching the new v2 entry point
- Zero remaining `ic://` references in the UI

Implementation notes
- Two separate useForm instances (linkForm, emailForm) with dedicated
  resolvers and submit handlers — clearer state boundaries than
  conditional validation on a merged schema
- Mode toggle uses role="group" + aria-pressed, focus-visible ring,
  keyboard-navigable
- Email result banner is role="status" for screen readers
- RPC client has one `as any` on `(api.my.meshes[":id"].invites as any)
  .email.$post` — the endpoint IS registered server-side (wave 2) but
  the monorepo's Hono type regen is out-of-band; TODO comment marks the
  cast for removal when the RPC types catch up
- No new deps
- Component export signature unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:57:25 +01:00
Alejandro Gutiérrez
fb7a84aed6 feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2)
Wires the v2 invite protocol end-to-end from a CLI user's perspective.
Broker foundation landed in c1fa3bc; this commit is the glue between
it and the human.

API (packages/api)
- createMyInvite now mints BOTH v1 token (legacy) AND v2 capability.
  Two-phase insert: row first (to get invite.id), then UPDATE with
  signed canonical bytes stored as JSON {canonical, signature} in the
  capabilityV2 column. Broker's claim handler parses the same shape.
- canonicalInviteV2 locked to `v=2|mesh_id|invite_id|expires_at|role|
  owner_pubkey_hex` — byte-identical to apps/broker/src/crypto.ts.
- brokerHttpBase() helper rewrites wss://host/ws → https://host for
  server-to-server calls.
- POST /api/public/invites/:code/claim — thin proxy to broker;
  passes status + body through, 502 broker_unreachable on fetch fail,
  cache-control: no-store.
- POST /api/my/meshes/:id/invites/email — mints a normal v2 invite
  via createMyInvite, records a pending_invite row, calls stubbed
  sendEmailInvite (logs TODO for Postmark wiring in a later PR).
- New schemas: claimInviteInput/ResponseSchema,
  createEmailInviteInput/ResponseSchema, v2 fields on
  createMyInviteResponseSchema.
- v1 paths untouched — legacy /join/[token] and /api/public/invite/:token
  continue to work throughout v0.1.x.

CLI (apps/cli)
- New `claudemesh join <code-or-url>` subcommand.
- Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345),
  or legacy ic://join/<token>. Detects v2 vs v1 and dispatches.
- v2 path: generates fresh ephemeral x25519 keypair (separate from
  the ed25519 identity) → POST /api/public/invites/:code/claim →
  unseals sealed_root_key via crypto_box_seal_open → persists mesh
  with inviteVersion: 2 and base64url rootKey to local config.
- Signature verification skipped with TODO — v0.1.x trusts broker;
  seal-open is already authenticated.
- apps/cli/src/lib/invite-v2.ts: generateX25519Keypair, claimInviteV2,
  parseV2InviteInput.
- state/config.ts: additive rootKey?/inviteVersion? fields.

CLI friction reducer
- apps/cli/src/index.ts: flag-first invocations
  (`claudemesh --resume xxx`, `claudemesh -c`, `claudemesh -- --model
  opus`) now route through `launch` automatically. Bare `claudemesh`
  still shows welcome; known subcommands dispatch normally.
- Removes one word of cognitive load: users never type `launch`.

No schema changes. No new deps. v1 fully backward compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:35:21 +01:00
Alejandro Gutiérrez
c1fa3bcb5c feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:11 +01:00
Alejandro Gutiérrez
dbea96960f fix(broker): plain text push messages, mesh slug in push label
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:27:22 +01:00
Alejandro Gutiérrez
a022da1998 fix(broker): show mesh slugs in /meshes + /status, remove all-meshes fallback
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- /meshes and /status now show mesh slug names instead of truncated IDs
- meshSlug cached on connect and loaded from DB join on boot
- Remove dangerous fallback that connected to ALL meshes in email flow
- BridgeRow now includes optional meshSlug field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:24:55 +01:00
Alejandro Gutiérrez
5df2664bae feat(web): rewrite hero (pain-first) + streamline page + enterprise tier
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Hero: 'Your Claude Code sessions work alone. claudemesh connects
them.' Three pain cards (context dies, teams relay by hand, setup
per developer). Solution paragraph focuses on shared wire with
E2E encryption.

Page: removed 5 redundant sections (Surfaces, LaptopToLaptop,
MeshVsMcp, MeetsYou, BeyondTerminal, DemoDashboard). Kept the
strongest: Hero, Features, WhatIsClaudemesh, Timeline, Pricing,
FAQ, CTA, MeshStats.

Pricing: added Enterprise tier with Contact sales → info@claudemesh.com.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:00:27 +01:00
Alejandro Gutiérrez
816c42feae docs: key points for landing page + outreach copy
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:56:40 +01:00
Alejandro Gutiérrez
4c0a417b7c docs: canonical pitch in founder's voice
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:54:59 +01:00
Alejandro Gutiérrez
e6962f1454 feat(web): /install route with server-side tracking
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Restores the curl installer script at /install. Adds:
- In-memory fetch counter (visible in container logs)
- Server-side PostHog event 'install_script_fetched' with
  IP, user-agent, and referer (fire-and-forget)
- Console log per fetch for monitoring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:57:37 +01:00
Alejandro Gutiérrez
1d506f3ea5 fix(web): add libsodium-wrappers to serverExternalPackages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Missing from standalone output → invite creation crashes with
'Cannot find module libsodium-wrappers' in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:34:15 +01:00
Alejandro Gutiérrez
64266a75f7 fix(broker): plain text for email verification prompt (markdown parse error)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Masked email with asterisks broke Telegram Markdown bold syntax.
Use plain text for the code prompt message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:15:10 +01:00
Alejandro Gutiérrez
2710f354a9 fix(broker): correct libsodium import in email connect callback
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Dynamic import returns module wrapper, need .default.ready then .default
for the actual sodium functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:09:32 +01:00
Alejandro Gutiérrez
6b55859d38 fix(broker): email connect searches userId + dashboardUserId + fallback
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Members created by CLI don't have dashboardUserId set. Now searches
by both userId and dashboardUserId columns. Falls back to all meshes
if no member link found (bootstrap case for mesh owners).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:02:04 +01:00
Alejandro Gutiérrez
7d31cc6283 fix(broker): email connect creates bridge member with fresh keypair
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The member table has no secretKey column (by design - keys are local).
Email verification now generates a fresh ed25519 keypair and creates
a new bridge-specific member entry for each mesh the user belongs to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:54:16 +01:00
Alejandro Gutiérrez
0403cfeb76 chore(cli): bump to v0.9.2 with connect telegram command
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:00:45 +01:00
Alejandro Gutiérrez
d8e6900072 feat(broker): email verification flow for telegram /connect
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Users can now type /connect in the bot → enter email → receive 6-digit
code → enter code → auto-connect to all meshes linked to that email.

Supports Resend and Postmark email providers via env vars.
Rate-limited to 5 code attempts, 10-min expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:00:02 +01:00
Alejandro Gutiérrez
ed8dab8bd3 fix(web): update email to alex@mourente.ai + correct LinkedIn URL
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:44:48 +01:00
Alejandro Gutiérrez
dad51870d9 feat(broker): file upload recipient picker in telegram bridge
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Instead of broadcasting files to all peers, the bot now uploads first
then shows an inline keyboard: individual peers, Everyone, or Keep private.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:43:55 +01:00
Alejandro Gutiérrez
a6af0f2154 security(broker): harden telegram bridge for production
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Validate JWT signature + expiry in /start (was only decoding, not verifying)
- Constant-time signature comparison in telegram-token.ts (prevent timing attacks)
- Rate limit /tg/token endpoint: 10 requests/hour per IP
- Grammy bot.catch() error handler (prevent unhandled rejections crashing broker)
- Cap WS reconnect attempts at 20 (prevent infinite retry loop)
- Expire stale pendingDMs entries (prevent memory leak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:20:59 +01:00
Alejandro Gutiérrez
0661e6223a fix(web): correct LinkedIn URL on about page
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00
Alejandro Gutiérrez
05e3c43e29 fix(web): scope webpack SVG loader to packages/ui only
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Exclude app/ SVGs (icon.svg, opengraph) from @svgr/webpack —
Next.js metadata loader handles those. Only transform flag/logo
SVGs from packages/ui/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:01:00 +01:00
Alejandro Gutiérrez
e3fa6e6a5e feat(cli): register connect/disconnect telegram commands
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:44:32 +01:00
Alejandro Gutiérrez
17066b4f6c fix(web): add webpack SVG loader (TURBOPACK=0 prod builds)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
turbopack.rules only applies to turbopack. When building with
TURBOPACK=0 (required for Payload CMS), webpack has no SVG rule.
Icons.UnitedKingdom returns an object → React #130. Adding a
webpack config rule for @svgr/webpack fixes both bundler paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:44 +01:00
Alejandro Gutiérrez
8d1685e64d fix(broker): upsert telegram bridge on reconnect (duplicate key)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:33:02 +01:00
Alejandro Gutiérrez
bb28e16c7d fix(broker): increase healthcheck start-period, catch Grammy errors
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:14:44 +01:00
Alejandro Gutiérrez
ac59d2acfe fix(broker): correct bot username claudemeshbot (no underscore)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:08:00 +01:00
Alejandro Gutiérrez
0a1af84712 fix(web): skip sherif postinstall in Docker build
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:56:42 +01:00
Alejandro Gutiérrez
18dc29aba1 feat(web): timeline section — 66 releases, every feature shipped
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Editorial timeline with vertical track, colored phase markers,
2-column feature grids per milestone. Shows v0.1→v0.8 evolution:
Foundation → Groups → Shared Intelligence → Files → Data Platform
→ Platform. Anchored by '66 npm releases. Every feature below is
in production today.' Dashed 'next' card at bottom for roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:52 +01:00
Alejandro Gutiérrez
795217093f fix(broker): wire telegram bridge boot + token endpoint into index.ts
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:49:56 +01:00
Alejandro Gutiérrez
61b0813924 fix(broker): add grammy dependency for telegram bridge
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:43:12 +01:00
Alejandro Gutiérrez
c10337ab9f chore: update lockfile for telegram bridge deps
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:38:53 +01:00
Alejandro Gutiérrez
126bbfeb2c feat(broker+cli): multi-tenant telegram bridge with 4 entry points
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- DB: mesh.telegram_bridge table + migration
- Broker: telegram-bridge.ts (Grammy bot + WS pool + routing)
- Broker: telegram-token.ts (JWT connect tokens)
- Broker: POST /tg/token endpoint + bridge boot on startup
- CLI: claudemesh connect/disconnect telegram commands
- Spec: docs/telegram-bridge-spec.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:03:11 +01:00
Alejandro Gutiérrez
c914f2b7db chore: update lockfile for telegram package
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:01:27 +01:00
Alejandro Gutiérrez
a8b9348b36 feat(broker+cli): telegram bridge and file download proxy
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:57:02 +01:00
Alejandro Gutiérrez
c3dd4efe82 feat(cli): enforce context:fork via Agent tool instruction in prompts/get
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Claude Code's MCP prompts path doesn't support the context field
natively. When a skill has context:"fork", prepend an instruction
telling the model to use the Agent tool with the specified agent
type and model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:16:00 +01:00
Alejandro Gutiérrez
a7d9ecab15 feat(broker): add cli-sync, member-api, jwt modules + DB schema updates
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
New broker endpoints for CLI auth sync flow (POST /cli-sync),
member profile management, and mesh settings. Includes JWT
verification for dashboard-issued sync tokens. DB schema adds
member profile fields and mesh policy columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:54:50 +01:00
Alejandro Gutiérrez
d263fe0f26 fix(cli): delay welcome notification for MCP init handshake
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Welcome was silently dropped when sent before Claude Code's
notifications/initialized. Add 2s delay after WS connects to
ensure the MCP handshake is complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:25:10 +01:00
Alejandro Gutiérrez
3226493e6d fix(cli): catch unhandled rejection in background wirePushHandlers
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:15:09 +01:00
Alejandro Gutiérrez
4cb5a97512 perf(cli): instant MCP startup — WS connects in background
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Move startClients() to run after server.connect(), not before.
MCP server is available to Claude Code in <0.5s instead of ~30s.
Tool handlers gracefully return errors until WS is ready.
Push event wiring happens in background callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:50 +01:00
Alejandro Gutiérrez
c080bc517f fix(web): stub all static asset extensions (.svg, .png, fonts) in ESM loader
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-09 01:09:38 +01:00
Alejandro Gutiérrez
471e88b3e6 fix(web): stub .scss/.sass/.less in addition to .css in ESM loader
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-09 00:52:39 +01:00
Alejandro Gutiérrez
c66e3adf67 fix(web): use absolute path for CSS stub loader in Docker
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-09 00:43:07 +01:00
Alejandro Gutiérrez
3f46a6657a fix(web): add CSS stub loader for Payload CMS route collection in Docker
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Node ESM can't handle .css imports during Next.js route collection.
This loader intercepts .css resolutions and returns empty modules,
fixing the build for all Payload deps (richtext-lexical, react-image-crop, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:35:04 +01:00
Alejandro Gutiérrez
83ba1aa373 fix(web): restore serverExternalPackages for Payload + use --webpack for build
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Root cause: Next.js 16 defaults to Turbopack for builds, but Payload CMS's
richtext-lexical imports .css files that fail during route collection in
Node ESM context.

Fix: add @payloadcms/richtext-lexical and @payloadcms/next back to
serverExternalPackages so Next.js skips their internal imports during
route collection. Use --webpack explicitly since Turbopack production
builds are incompatible with Payload (payloadcms/payload#14786).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:26:06 +01:00
Alejandro Gutiérrez
7430e4ffe0 fix(web): header nav links → real pages (docs, blog, about, changelog)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:24:33 +01:00
Alejandro Gutiérrez
d72e49b8fd fix(web): header GitHub link → claudemesh-cli repo
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:18:53 +01:00
Alejandro Gutiérrez
3f57944921 chore(cli): bump version to 0.9.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:01:58 +01:00
Alejandro Gutiérrez
b31aab8aeb feat(cli+broker): expose mesh skills as MCP prompts and skill:// resources
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Claudemesh MCP server now declares prompts:{} and resources:{} capabilities.
Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code
via prompts/list+get, and as skill://claudemesh/{name} resources for the
upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use,
allowed_tools, model, context, agent) stored in the manifest jsonb column.
Change notifications sent on share/remove so Claude Code refreshes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:01:06 +01:00
Alejandro Gutiérrez
5db9842261 docs: add git deploy test result (45/45 pass)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:08:09 +01:00
Alejandro Gutiérrez
81e520fdbb docs: update test results — 44/44 pass, CLI 0.8.0-0.8.9
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:54:53 +01:00
Alejandro Gutiérrez
26c4502277 fix(cli): display system push messages without decryption
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:12:49 +01:00
Alejandro Gutiérrez
bfc62b9a72 fix(cli): display system push messages without decryption
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:51:12 +01:00
Alejandro Gutiérrez
f8c6f9ae74 feat(broker): add test endpoints for url watch validation
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:37:23 +01:00
Alejandro Gutiérrez
3497700fad feat: url watch — broker polls URLs, notifies on change
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:29:43 +01:00
Alejandro Gutiérrez
2c156f832e docs: add test results for mesh services platform (37/37 pass)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:37:47 +01:00
Alejandro Gutiérrez
4ee810242d fix(broker): restore services in failed/crashed/restarting states too
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:30:15 +01:00
Alejandro Gutiérrez
b6224c4186 fix(broker): sync with runner on boot instead of re-deploying
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Boot restore now checks runner /health to see what's already running,
then updates DB status to match. Fixes the bug where broker restart
marked running services as 'failed' because it tried to re-deploy
without shared source volume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:26:43 +01:00
Alejandro Gutiérrez
4c385a16cc fix(runner): use python -m for Python MCPs instead of CLI binary
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:38:31 +01:00
Alejandro Gutiérrez
4ae6a86bf6 fix(runner): retry MCP init for slow Python startup
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:37:04 +01:00
Alejandro Gutiérrez
c327c282e3 fix(runner): install mcp[cli] extras for Python MCPs
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:35:16 +01:00
Alejandro Gutiérrez
e645455b22 fix(runner): run Python venv binaries directly, not via node
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:34:29 +01:00
Alejandro Gutiérrez
45505a1635 fix(runner): fix uvx variable scoping bug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:33:51 +01:00
Alejandro Gutiérrez
17e6361d64 fix(runner): uv venv --clear for redeployments
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:52 +01:00
Alejandro Gutiérrez
528e7e21b1 fix(runner): use uv pip install for Python venv
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:13 +01:00
Alejandro Gutiérrez
7b875de301 feat(runner): add uvxPackage source type for Python MCPs
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:30:30 +01:00
Alejandro Gutiérrez
8a3c96dc7c fix(runner): prefer package-matching binary over utility bins
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:28:50 +01:00
Alejandro Gutiérrez
b0634b829c fix(runner): set GIT_TERMINAL_PROMPT=0 for non-interactive clone
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:27:10 +01:00
Alejandro Gutiérrez
2bd388a5e2 fix(runner): add missing writeFileSync import
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:22:12 +01:00
Alejandro Gutiérrez
71c0767a1b feat: runner accepts git/npx sources, broker delegates extraction
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Runner /load now accepts gitUrl, npxPackage, or sourcePath. It handles
git clone and npm install internally. Broker no longer needs shared
volume for source extraction — just tells the runner what to fetch.

CLI mesh_mcp_deploy now supports npx_package as a third source type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:18:25 +01:00
Alejandro Gutiérrez
6a3f087209 fix(runner): add unzip for bun install in Dockerfile
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:08:27 +01:00
Alejandro Gutiérrez
873f588057 feat: runner container + broker deploy pipeline
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- apps/runner/: Dockerfile (node22 + python3 + uv + bun) + supervisor.mjs
  (HTTP API for load/call/unload/health)
- docker-compose: runner service with shared services-data volume
- Broker mcp_deploy: git clone or zip extract → runner /load → MCP spawn
- Broker mcp_call: routes managed services to runner via HTTP, falls back
  to live-proxy for peer-hosted servers
- RUNNER_URL env var for broker → runner communication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:06:43 +01:00
Alejandro Gutiérrez
070a3b7422 feat(broker): encrypt env vars at rest, restore on reboot
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- broker-crypto.ts: AES-256-GCM encrypt/decrypt with BROKER_ENCRYPTION_KEY
- mcp_deploy stores env as _encryptedEnv in mesh.service.config (no plaintext in DB)
- boot restore: decrypts _encryptedEnv and re-spawns services via service-manager
- auto-generates ephemeral key if BROKER_ENCRYPTION_KEY not set (logs warning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:25:48 +01:00
Alejandro Gutiérrez
75ca892ea7 feat(cli): vault_get + deploy-time vault resolution
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Add vault_get wire message to fetch encrypted entries for client-side
  decryption
- Deploy handler resolves $vault: refs: fetches encrypted entries from
  broker, decrypts with mesh keypair locally, sends resolved env over TLS
- File-type vault entries encoded as __vault_file__:path:base64 for
  runner-side extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:16:46 +01:00
Alejandro Gutiérrez
a90046a8e3 fix(cli): e2e encrypt vault entries with libsodium
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:10:23 +01:00
Alejandro Gutiérrez
02a165dd76 feat(cli): add --resume and --continue flags to launch
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
claudemesh launch now supports:
  --resume <id> / -r  — resume a previous Claude Code session
  --continue / -c     — continue the most recent conversation

When resuming, skips generating a new session ID so the mesh peer
identity persists. The detectClaudeSessionId() fallback in ws/client.ts
picks up the existing session UUID from the .jsonl file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:57:24 +01:00
Alejandro Gutiérrez
52393429f9 feat(cli): use Claude Code session ID for mesh peer identity
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
claudemesh launch now generates a UUID and passes it to claude via
--session-id flag + CLAUDEMESH_SESSION_ID env var. The MCP server
reads this and sends it in the hello handshake.

Fallback: when launched without claudemesh launch (e.g., claude --resume),
detectClaudeSessionId() scans ~/.claude/projects/ for the most recent
.jsonl file and extracts the session UUID from the filename.

Benefits:
- Broker detects reconnections (same session = restore state)
- Multiple peers in same project dir get unique identities
- Session identity persists across --resume

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:38:44 +01:00
Alejandro Gutiérrez
9474d985ae fix(cli): add missing tool call handlers for vault + service tools
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The Wave 3I handlers (vault_set, vault_list, vault_delete, mesh_mcp_deploy,
mesh_mcp_undeploy, mesh_mcp_update, mesh_mcp_logs, mesh_mcp_scope,
mesh_mcp_schema, mesh_mcp_catalog, mesh_skill_deploy) were lost during
the re-apply phase. Tools were registered in tools/list but returned
"Unknown tool" because the switch cases in server.ts were missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:25:18 +01:00
Alejandro Gutiérrez
643c808685 docs(web): 2-command onboarding — install + launch --join
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Simplify getting-started to 2 steps: npm install + launch --join.
Remove "claudemesh install" section, update join page to show
launch --join as the primary flow, update invite format examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:13:21 +01:00
Alejandro Gutiérrez
2c24f667f9 refactor(web): remove install script, simplify onboarding to 3 steps
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Drop /install route (curl|bash script). Install is just `npm i -g
claudemesh-cli`. Update hero, FAQ, getting-started, and join flow to
reflect the simplified 3-step onboarding: install → join → launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:09:17 +01:00
Alejandro Gutiérrez
b0113913f2 chore: bump cli to 0.8.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:54:16 +01:00
Alejandro Gutiérrez
e1cafa54b3 feat: mesh services platform — deploy MCP servers, vaults, scopes
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Add the foundation for deploying and managing MCP servers on the VPS
broker, with per-peer credential vaults and visibility scopes.

Architecture:
- One Docker container per mesh with a Node supervisor
- Each MCP server runs as a child process with its own stdio pipe
- claudemesh launch installs native MCP entries in ~/.claude.json
- Mid-session deploys fall back to svc__* dynamic tools + list_changed

New components:
- DB: mesh.service + mesh.vault_entry tables, mesh.skill extensions
- Broker: 19 wire protocol types, 11 message handlers, service catalog
  in hello_ack with scope filtering, service-manager.ts (775 lines)
- CLI: 13 tool definitions, 12 WS client methods, tool call handlers,
  startServiceProxy() for native MCP proxy mode
- Launch: catalog fetch, native MCP entry install, stale sweep, cleanup,
  MCP_TIMEOUT=30s, MAX_MCP_OUTPUT_TOKENS=50k

Security: path sanitization on service names, column whitelist on
upsertService, returning()-based delete checks, vault E2E encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:03 +01:00
Alejandro Gutiérrez
a4f2e0aa81 feat(web): mesh structure section (tree + coordination patterns)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Shows the full hierarchy: Organization → Mesh → @groups → Peers
with live state + memory. Six coordination patterns below with
code snippets: lead-gather, delegation, voting, chain review,
broadcast, targeted views. Footer: 'All patterns are conventions
in system prompts. The broker routes; Claude coordinates.'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:20:31 +01:00
Alejandro Gutiérrez
cbcde4d910 feat(web): capability stack diagram below the wire
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Shows the 12 capability categories that flow through the mesh:
messages, groups, state, memory, files, SQL, vectors, graph,
tasks, context, streams, scheduled. Each with a mono icon tag
and one-line description. Anchored by '43 MCP tools, 5
persistence backends' footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:14:38 +01:00
Alejandro Gutiérrez
495c234159 fix(web): enable turbopack + payload by unbundling richtext-lexical
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The CSS import error was caused by richtext-lexical being in
serverExternalPackages — Node can't require .css files. Removing
it lets Turbopack bundle it (handling CSS natively). Other payload
packages stay external (they don't import CSS).

Restores turbopack as the default production bundler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:38:56 +01:00
Alejandro Gutiérrez
42c1d02f5e docs: add game architecture vision — NPCs as data, AI on demand
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
NPCs are mesh data (skills, memory, state), not peers. One API call
per interaction, 3 coordinator peers per faction. Game connector
assembles context from mesh and calls any LLM on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:30:13 +01:00
Alejandro Gutiérrez
a33c925216 docs: add simulation controller SDK spec, replace spatial topology
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The mesh is the communication fabric, not the simulation engine.
SimController pattern: external controller drives tick loop, computes
visibility, sends observations to peers, collects actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:38:43 +01:00
Alejandro Gutiérrez
6ab3fbbea3 fix(web): fix getting-started metadata export, use TURBOPACK=0 env for build
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- generateMetadata instead of metadata (getMetadata returns a function)
- Use TURBOPACK=0 env prefix instead of --no-turbopack flag (not recognized in Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:37:10 +01:00
Alejandro Gutiérrez
26adbafde2 fix(web): remove --no-turbopack from build script (Docker uses ENV TURBOPACK=0)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The --no-turbopack flag isn't recognized when Next.js runs inside the
Docker builder stage. The Dockerfile already sets ENV TURBOPACK=0 which
achieves the same effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:29:20 +01:00
Alejandro Gutiérrez
13e8ce07ac chore: bump cli to 0.7.1
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:57 +01:00
Alejandro Gutiérrez
5398ca6833 feat: make MCP server registrations persistent across peer disconnects
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Persistent MCP servers (opt-in via `persistent: true`) survive host
disconnects — they appear as offline in mcp_list and auto-restore when
the host reconnects. Ephemeral servers (default) still clean up on
disconnect. Offline servers return a clear error on mcp_call with
time-since-disconnect info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:06 +01:00
Alejandro Gutiérrez
56b1cc0756 docs: split vision into changelog + clean roadmap
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
changelog-20260407.md: full implementation details for 21 features
vision-20260407.md: slimmed to shipped summary + remaining items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:55 +01:00
Alejandro Gutiérrez
fc8a7edc23 feat: persist peer session state across disconnects ("welcome back" on reconnect)
Save groups, profile, visibility, summary, display name, and cumulative
stats to a new mesh.peer_state table on disconnect. On reconnect (same
meshId + memberId), restore them automatically — hello groups take
precedence over stored groups if provided. Broadcast peer_returned
system event with last-seen time and summary to other peers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:20 +01:00
Alejandro Gutiérrez
e09671cdcb feat: broadcast system notifications on MCP server register/unregister
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Peers now receive [system] notifications when MCP servers join or
leave the mesh, with tool names and hosting peer info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:58 +01:00
Alejandro Gutiérrez
32fc4a0c98 fix: align connector-slack and connector-telegram deps with workspace versions
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Sherif enforces consistent dependency versions across the monorepo.
The connectors used ^8.0.0 for ws and @types/ws while the rest used
exact 8.20.0 / 8.5.13. Also sorted dependencies alphabetically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:19 +01:00
Alejandro Gutiérrez
b315b31cc9 docs: add peer session persistence and MCP notification to vision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:15:49 +01:00
Alejandro Gutiérrez
21cb6efced docs: mark all implemented vision items with commit refs
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
17 of 22 items done, 2 partial. Updated all section headers and
added implementation notes with commits and timestamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:37 +01:00
Alejandro Gutiérrez
125b576e2c chore: update pnpm lockfile for connector-slack and connector-telegram deps
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The lockfile was stale — connector-slack/package.json added 7 deps that
weren't reflected in pnpm-lock.yaml, causing frozen-lockfile builds to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:13 +01:00
Alejandro Gutiérrez
3641618391 docs(mcp): add file access decision guide to instructions
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Teaches AI when to use filesystem (local), read_peer_file (remote
<1MB), or share_file (persistent, no size limit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:09:09 +01:00
Alejandro Gutiérrez
a92cf6b629 feat: hint AI to use filesystem for local peer file reads
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
When read_peer_file targets a local peer (same hostname), prepend a
hint with the direct filesystem path. Still executes the relay as
fallback — AI learns the shortcut without being blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:06:27 +01:00
Alejandro Gutiérrez
2c9c8c7b6c feat: add hostname to hello + local/remote peer locality detection
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Peers report os.hostname() in the hello handshake. list_peers shows
[local] or [remote] tag per peer. MCP instructions teach AI to read
local peers' files directly via filesystem instead of relay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:05:46 +01:00
Alejandro Gutiérrez
98fda20ab6 chore: bump cli to 0.7.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:01:09 +01:00
Alejandro Gutiérrez
025a53a70c docs: update vision — 17 of 23 items implemented, add telemetry idea
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:00:37 +01:00
Alejandro Gutiérrez
b55cf269a4 feat: implement inbound webhooks for external service integration
Add the webhook handler module (webhooks.ts) that verifies secrets
against the mesh.webhook table and broadcasts incoming HTTP POST
payloads to all connected mesh peers. This completes the webhook
feature whose schema, types, WS CRUD handlers, and CLI tools were
added in the previous commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:58:01 +01:00
Alejandro Gutiérrez
504111c50c feat: add read_peer_file and list_peer_files MCP tools
Wire up MCP tool handlers for the peer file sharing relay. Peers can
now read files and list directories from other peers' local filesystems
through the mesh broker. Includes name-to-pubkey resolution, base64
decode, and instructions table update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:56:42 +01:00
Alejandro Gutiérrez
05d9b56f28 feat: implement simulation clock with configurable time multiplier
Broker-driven clock that broadcasts periodic heartbeat ticks to all
peers in a mesh. Speed is configurable from x1 (real-time, 60s ticks)
to x100 (600ms ticks) for load testing simulations. Auto-pauses when
the last peer disconnects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:14 +01:00
Alejandro Gutiérrez
c8cb1e3ea5 feat: implement mesh skills catalog — peers publish and discover reusable instructions
Adds share_skill, get_skill, list_skills, and remove_skill across the full
stack (Drizzle schema, broker CRUD + WS handlers, CLI client methods, MCP
tools). Skills are mesh-scoped, unique by name, and searchable via ILIKE
on name/description/tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:03 +01:00
Alejandro Gutiérrez
86a258301f feat: implement signed hash-chain audit log for mesh events
Add tamper-evident audit logging where each entry includes a SHA-256
hash of the previous entry, forming a verifiable chain per mesh.
Events tracked: peer_joined, peer_left, state_set, message_sent
(never logs message content). New WS handlers: audit_query for
paginated retrieval, audit_verify for chain integrity verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:54:57 +01:00
Alejandro Gutiérrez
7e102a235b feat: add @claudemesh/sdk standalone client library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:46 +01:00
Alejandro Gutiérrez
5563f90733 feat: add @claudemesh/sdk package for non-Claude-Code clients
Standalone TypeScript SDK that any process can use to join a mesh and
send/receive messages. Implements the same WS protocol and libsodium
crypto_box encryption as the CLI, with an EventEmitter-based API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:22 +01:00
Alejandro Gutiérrez
b3b9972e60 feat: add peer stats reporting (messages, tool calls, uptime, errors)
Peers self-report resource usage via set_stats; stats visible in
list_peers responses and the new mesh_stats MCP tool. CLI auto-reports
every 60s and tracks messagesIn/Out, toolCalls, uptime, and errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:26 +01:00
Alejandro Gutiérrez
fe9285351b feat: add Telegram connector package for mesh-to-chat bridging
Introduces @claudemesh/connector-telegram — a standalone bridge process
that joins a mesh as peerType: "connector" and relays messages
bidirectionally between a Telegram chat and mesh peers via long polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:00 +01:00
Alejandro Gutiérrez
08e289a5e3 feat: implement mesh MCP proxy — dynamic tool sharing between peers
Peers can register MCP servers with the mesh and other peers can invoke
those tools through the existing claudemesh connection without restarting.

Broker: in-memory MCP registry with mcp_register/unregister/list/call
handlers, call forwarding to hosting peer with 30s timeout, and automatic
cleanup on peer disconnect.

CLI: mcpRegister/mcpUnregister/mcpList/mcpCall client methods, inbound
mcp_call_forward handler, and 4 new MCP tools (mesh_mcp_register,
mesh_mcp_list, mesh_tool_call, mesh_mcp_remove).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:54 +01:00
Alejandro Gutiérrez
7d432b3aaa feat(web): add state timeline and resource panels to live mesh dashboard
Two new panels below the existing peer graph + live stream grid:
- StateTimelinePanel: vertical timeline of audit events and presence
  status changes, auto-scrolling, sorted newest-first
- ResourcePanel: 2x2 card grid showing live peers, envelopes by
  priority, audit event breakdown, and session status

Both share the same TanStack Query cache key as the existing panels
(no extra API calls). Matches the --cm-* dark terminal aesthetic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:18 +01:00
Alejandro Gutiérrez
b0dc538119 feat(cli): nudge user to join a mesh when install finds none
After MCP registration and hooks setup, `claudemesh install` now checks
the config for joined meshes. If empty, it prints actionable guidance
(join command + dashboard URL) instead of the generic "Next:" line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:49:37 +01:00
Alejandro Gutiérrez
27c9d2a02c docs: add peer visibility, spatial topology, and public profiles to vision
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:41:06 +01:00
Alejandro Gutiérrez
87e0d0004d docs: mark 5 vision items as implemented with commit refs
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:37:22 +01:00
Alejandro Gutiérrez
dba0fb7b33 chore: bump cli to 0.6.9
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:36:03 +01:00
Alejandro Gutiérrez
72be651ca8 feat(cli): add --cron flag to remind command
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:40 +01:00
Alejandro Gutiérrez
db2bf3ea06 docs(protocol): add missing message types and new features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:15 +01:00
Alejandro Gutiérrez
e87380775f feat: add persistent cron-based recurring reminders
Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:47 +01:00
Alejandro Gutiérrez
58ba01f20f fix(cli): sync CLAUDEMESH_TOOLS with current tool definitions and sort alphabetically
Add 4 missing tools (cancel_scheduled, grant_file_access, list_scheduled,
schedule_reminder) and sort the array alphabetically for maintainability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:02 +01:00
Alejandro Gutiérrez
59332dc47d feat(web): add peer graph visualization to live mesh dashboard
Renders peers as SVG nodes in a radial layout with animated edges
showing real-time message traffic. Shares the same TanStack Query
cache as LiveStreamPanel (same queryKey). Side-by-side on desktop,
stacked on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:32:41 +01:00
Alejandro Gutiérrez
f34b8fbc6b docs(cli): improve --help text for clarity, concision, and consistency
Rewrite all command and argument descriptions in index.ts to follow
imperative mood, omit filler, use backtick-formatted values, and
surface key behaviors (e.g. launch spawns Claude Code with MCP,
remind supports list/cancel subactions, send accepts @group and *).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:55 +01:00
Alejandro Gutiérrez
79525af42e fix(broker): remove cron example from JSDoc that broke TSC
The "0 */2 * * *" cron example inside a /** comment caused TSC to
parse */ as end-of-comment, producing syntax errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:31 +01:00
Alejandro Gutiérrez
69e93d4b8c feat(cli): add mesh templates and claudemesh create command
Predefined mesh configurations (dev-team, research, ops-incident,
simulation, personal) let users bootstrap meshes with groups, roles,
state keys, and system prompt hints. Templates are bundled at build
time via Bun's JSON import support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:12 +01:00
Alejandro Gutiérrez
810f372d1c feat: add peer metadata (peerType, channel, model) and cwd to peer list
Extend the WS hello handshake with optional peerType, channel, and model
fields so peers can advertise what kind of client they are. The broker
stores these in-memory on PeerConn and returns them (along with cwd) in
the peers_list response. CLI peers command and MCP list_peers tool now
display the new metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:30:04 +01:00
Alejandro Gutiérrez
453705a4e1 feat: broadcast system notifications on peer join/leave
When a peer connects or disconnects, the broker now broadcasts a
system push (subtype: "system") to all other peers in the same mesh.
The CLI formats these as [system] channel notifications so AI sessions
can react to topology changes without polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:28:49 +01:00
Alejandro Gutiérrez
5cb4cc4fe7 feat(web): update landing page copy for full feature surface, add getting started + mesh vs MCP
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Landing page copy was stuck at the v0.1 feature set (messaging + state + memory + groups).
The CLI now ships 43 MCP tools across 5 persistence backends. This commit brings the site
copy in sync with what's actually built.

Changes:
- Hero, features, pricing, FAQ, CTA, footer: reflect 43 tools, files, SQL, vectors, graphs
- Features section: expanded from 4 tabs to 7 (added Files, Database, Vectors)
- New /getting-started page: full install guide with correct 4-step flow
- New Mesh vs MCP section: side-by-side diagrams + 8-row comparison table
- Fix: install-toggle on /join page had `npx claudemesh@latest init` (init doesn't exist)
  → replaced with `curl -fsSL https://claudemesh.com/install | bash`
- Navigation: added Getting Started to header, footer, hero link
- COPY.md synced with all 6 capability areas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:58:22 +01:00
Alejandro Gutiérrez
eeac47c360 chore: bump cli to 0.6.8
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:39:21 +01:00
Alejandro Gutiérrez
0bb9d71a26 feat: merge schedule_reminder + send_later, add subtype reminder
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
- Merge send_later into schedule_reminder (optional `to` param — omit for self-reminder)
- Add subtype?: "reminder" to WSPushMessage, WSScheduleMessage, ScheduledEntry, InboundPush
- Broker handleSend now accepts optional subtype and injects into push envelope
- deliver closure passes sm.subtype so reminders surface correctly
- MCP channel meta includes subtype field; formatPush tags [REMINDER] in check_messages
- MCP server instructions document subtype and schedule_reminder/list_scheduled/cancel_scheduled
- client.scheduleMessage accepts isReminder flag, sends subtype: "reminder" on wire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:38:41 +01:00
Alejandro Gutiérrez
3ff7a61e3f chore: update pnpm lockfile after citty + scheduled message deps
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:58:22 +01:00
Alejandro Gutiérrez
e76ade64d2 feat: scheduled messages — schedule_reminder, send_later, list_scheduled, cancel_scheduled
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Broker: schedule/list_scheduled/cancel_scheduled WS message types + in-memory delivery
- Client: scheduleMessage(), listScheduled(), cancelScheduled() with resolver Map pattern
- MCP: schedule_reminder, send_later, list_scheduled, cancel_scheduled tools
- CLI: claudemesh remind <msg> --in 2h | --at 15:00 | list | cancel <id>
- Types: WSScheduleMessage, WSScheduledAckMessage, WSScheduledListMessage, WSCancelScheduledAckMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:53:42 +01:00
Alejandro Gutiérrez
59848f0d3e chore(cli): v0.6.6 — correlation ID refactor (resolver Maps + _reqId)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-04-07 14:31:29 +01:00
Alejandro Gutiérrez
d0fa1c028f fix(broker): echo _reqId in all WS responses for correlation ID routing
Extract _reqId from incoming WS messages and include it in every direct
response sendToPeer call and sendError call. Clients can now match
responses to requests by ID instead of relying on FIFO ordering.
Old clients without _reqId are unaffected (field simply omitted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:28:30 +01:00
Alejandro Gutiérrez
8f925d9a9e fix(cli): correlation ID refactor — resolver Maps with _reqId + FIFO fallback
Replace all 22 resolver Array<fn> patterns with Map<reqId, {resolve, timer}>.
Outgoing messages now include _reqId; on response the broker's echoed _reqId
is used for exact matching, with FIFO fallback for brokers that don't echo it.
Add makeReqId() helper and resolveFromMap() utility. Error propagation block
updated to iterate Maps and pop the oldest entry across all queues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:25:51 +01:00
Alejandro Gutiérrez
4ce1034dcd chore(cli): v0.6.5 — all bug fixes batch
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-07 13:12:29 +01:00
Alejandro Gutiérrez
e26a36e543 fix(broker): vector_stored type, set_state no-resp, subscribe ack
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- vector_store sends {type:"vector_stored",id}; wrapped in try/catch
- set_state no longer sends state_result (fire-and-forget)
- subscribe sends {type:"subscribed",stream} confirmation
- remove broken myPresence lookup in mesh_info
- add WSVectorStoredMessage + WSSubscribedMessage to types union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:08:06 +01:00
Alejandro Gutiérrez
60c74d9463 fix(broker): shareContext stable upsert key + createStream atomic upsert
- shareContext: adds optional memberId param; when provided, upserts on
  (meshId, memberId) instead of (meshId, presenceId) — prevents stale
  context rows accumulating on every reconnect. Falls back to presenceId
  for legacy/anonymous connections. Also refreshes presenceId on update
  so it stays current.
- schema: adds member_id column + unique index context_mesh_member_idx
  on mesh.context table; new migration 0013_context-stable-member-key.sql.
- index.ts call site updated to pass conn.memberId as the stable key.
- createStream: replaces SELECT-then-INSERT TOCTOU race with atomic
  INSERT ... ON CONFLICT DO NOTHING RETURNING, followed by SELECT on miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:58 +01:00
Alejandro Gutiérrez
6fba9bd4eb feat(cli): fix field mismatches + error propagation
- claim_task/complete_task: send taskId not id
- graph_result: read msg.records not msg.rows
- message_status: try all mesh clients, not only first
- broker: omit state_result for set_state (fixes get_state cross-contamination)
- error handler: unblock first pending resolver on unmatched broker errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:25 +01:00
Alejandro Gutiérrez
5bcc1fe323 chore(cli): bump to v0.6.4 — fix get_file sealedKey bug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-07 12:57:20 +01:00
Alejandro Gutiérrez
e70f0ed1ff fix(broker/cli): e2e get_file owner sealedKey bug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
broker: owner also fetches sealedKey from mesh.file_key (not skipped),
  only non-owners are blocked when key is missing
cli: explicit error when encrypted file has no sealedKey (no silent raw download)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:56:36 +01:00
Alejandro Gutiérrez
5f696f47ea feat(cli): v0.6.3 — e2e file crypto module + encrypted share_file
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- add crypto/file-crypto.ts: encryptFile, decryptFile, sealKeyForPeer, openSealedKey
- update share_file: when to= set, encrypts file + seals key per recipient
- update get_file: decrypts if encrypted + sealedKey present
- add grant_file_access tool: re-seals Kf for a new peer without re-upload
- add getSessionPubkey/getSessionSecretKey getters on BrokerClient
- add grantFileAccess WS method on BrokerClient
- bump version to 0.6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:53:13 +01:00
Alejandro Gutiérrez
ccb9fb2a68 feat(broker/db): e2e file encryption schema + db functions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey)
- add encrypted + ownerPubkey columns to mesh.file
- export insertFileKeys, getFileKey, grantFileKey from broker.ts
- update uploadFile/getFile/listFiles to include encrypted/ownerPubkey
- migration 0012_add-file-encryption applied to prod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:43:57 +01:00
Alejandro Gutiérrez
898c061089 feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:33:39 +01:00
Alejandro Gutiérrez
f7a6559429 feat(broker): add E2E file encryption to HTTP upload and WS handlers
- parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost
- pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after
- get_file: fetch sealedKey for non-owners, block if missing, include in response
- list_files: include encrypted field per file
- add grant_file_access WS handler so owners can seal keys for peers
- update types.ts with new message interfaces and union members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:32:46 +01:00
Alejandro Gutiérrez
579d0c3d3e chore: bump version to 0.6.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:21:03 +01:00
Alejandro Gutiérrez
190f5a958e refactor(cli): migrate to citty — --help generated from flag definitions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Replace manual switch + HELP string with citty defineCommand/runMain.
Flag definitions in index.ts are now the single source of truth for
--help output. Remove parseArgs() from launch.ts; accept citty-parsed
flags + rawArgs (-- passthrough to claude preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:19:16 +01:00
Alejandro Gutiérrez
03661e1b68 docs(cli): expand --help with all launch flags, groups hierarchy, env vars
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:04 +01:00
Alejandro Gutiérrez
d451fc296e feat: hierarchical group routing + role wiring
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team

cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:09:37 +01:00
Alejandro Gutiérrez
3da5d71275 fix(broker): fix share_file DB insert failures
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Normalise tags to Array before Drizzle insert (PgArray mapper calls
  .map() and throws if value is not a standard JS Array)
- Use uploadedByName instead of uploadedByMember FK — the X-Member-Id
  header carries the mesh slug, not a mesh.member primary key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:56:43 +01:00
Alejandro Gutiérrez
cdf335f609 fix(broker): fix MINIO_USE_SSL env coercion
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
z.coerce.boolean() treats any non-empty string as true, so MINIO_USE_SSL="false" → true.
Switch to explicit enum+transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:38:06 +01:00
Alejandro Gutiérrez
0cd16ff358 fix: exclude sender only for broadcasts, not direct messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:34:09 +01:00
Alejandro Gutiérrez
3e9707276d fix: add diagnostic logging to maybePushQueuedMessages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:21:29 +01:00
567 changed files with 79649 additions and 42093 deletions

View File

@@ -0,0 +1,243 @@
# CLI Wizard Architecture Refactor
**Status:** backlog
**Created:** 2026-04-10
**Source:** Reverse-engineered from `@posthog/wizard` (npm cache), applied to `apps/cli/src/commands/launch.ts`
## Why
Launch wizard has three compounding problems:
1. **Imperative branching**`launch.ts` checks account → mesh → name → role → exec in hardcoded order. Adding a screen requires touching existing code. Hard to reason about `--resume`, `--non-interactive`, and skip conditions.
2. **Terminal bleed-through on handoff** — wizard→`claude` exec corrupts Ink's TUI state (garbled word wraps, tool labels overwritten, spinner fragments fused to paths). Root cause is spread across multiple exit paths instead of one choke point.
3. **Inconsistent visual design** — ad-hoc colors per file, no central palette, no shared icon set, no shared layout primitives. Every screen reinvents status rows, centering, and spacing.
PostHog's wizard solves all three with one architectural pattern: **declarative flow pipelines + session-as-store + shared visual primitives**. This artifact captures the plan to port that pattern.
## What PostHog does (the reference)
### Flow pipeline (`flows.ts` + `router.ts`)
Each wizard flow is an array of screen entries:
```ts
export const FLOWS = {
[Flow.Wizard]: [
{ screen: Screen.Intro, isComplete: s => s.setupConfirmed },
{ screen: Screen.HealthCheck, isComplete: s => s.readinessResult !== null },
{ screen: Screen.Setup, show: needsSetup, isComplete: s => !needsSetup(s) },
{ screen: Screen.Auth, isComplete: s => s.credentials !== null },
{ screen: Screen.Run, isComplete: s => s.runPhase === RunPhase.Completed },
{ screen: Screen.Outro, isComplete: s => s.outroDismissed },
],
};
```
The router walks the array, skips entries where `show(s) === false` or `isComplete(s) === true`, and returns the first remaining entry. Zero switch statements. Zero hardcoded transitions. Adding a screen = appending an object.
### Overlay stack
Separate from the linear flow cursor. Interrupts (port conflict, auth expired, managed settings) are pushed onto `overlays[]` from anywhere and popped when dismissed. Active screen = top of overlay stack OR flow cursor. Flows never need to know about interrupts.
### Session as single source of truth
One `WizardStore` holds all session state. Screens subscribe via React 18 `useSyncExternalStore`. Completion predicates read session; imperative code writes session; the router re-resolves on every change.
### Visual primitives
- `styles.ts` — 6-color palette (`Colors`), 9-icon set (`Icons`), alignment enums (`HAlign`, `VAlign`)
- `CardLayout` — semantic centering wrapper used by every screen
- `PickerMenu` — the only selection primitive, used for every choice
- `screen-registry.ts` — maps `Screen` enum → React component
- Brand mark: three colored `█` blocks next to the wizard name on every screen header
## What claudemesh should do
### Target file layout
```
apps/cli/src/
├── commands/
│ └── launch.ts # thin entrypoint: parse flags → start TUI
└── ui/
├── styles.ts # palette, icons, alignment enums
├── store.ts # LaunchStore (session + subscribe)
├── router.ts # flow cursor + overlay stack
├── flows.ts # FLOWS = { Launch: [...], Join: [...] }
├── screen-registry.ts # Screen enum → component
├── primitives/
│ ├── CardLayout.tsx
│ ├── PickerMenu.tsx
│ ├── StatusRows.tsx # new: "Directory ✓ /claudemesh" pattern
│ ├── BrandMark.tsx # new: 3 colored squares + label
│ └── LoadingBox.tsx
└── screens/
├── WelcomeScreen.tsx
├── AccountScreen.tsx
├── MeshPickerScreen.tsx
├── NameRoleScreen.tsx
├── ConfirmScreen.tsx
└── HandoffScreen.tsx # last screen; its unmount triggers exec claude
```
### Flow definition
```ts
export const FLOWS = {
[Flow.Launch]: [
{ screen: Screen.Welcome, isComplete: s => s.welcomed },
{ screen: Screen.Account, show: s => !s.hasAccount, isComplete: s => s.hasAccount },
{ screen: Screen.MeshPicker, show: s => s.meshes.length > 1, isComplete: s => s.meshSlug !== null },
{ screen: Screen.NameRole, isComplete: s => s.displayName !== null && s.role !== null },
{ screen: Screen.Confirm, isComplete: s => s.confirmed },
{ screen: Screen.Handoff, isComplete: () => false }, // terminal screen
],
};
```
### `--resume` works for free
`--resume <id>` populates the session from saved state; every satisfied predicate auto-skips. The wizard renders only the screens that still need input. No special `--resume` branches in screen code.
### `--non-interactive` works for free
Non-interactive mode: walk the flow, for each incomplete entry check if its required session fields can be sourced from CLI flags. If yes, populate and continue. If no, **fail fast with a clear message** naming the missing flag. Never silently guess defaults.
```
$ claudemesh launch --non-interactive --name Alexis
✗ Missing --mesh (required in non-interactive mode when >1 mesh joined)
Available meshes: alexis-mou, dev, staging
```
### Overlay interrupts claudemesh needs
- `BrokerDisconnect` — WS dropped mid-wizard, retry countdown
- `InviteInvalid` — paste invite screen rejected token
- `MeshNotFound``--mesh foo` passed but not joined
- `RateLimit` — broker rate limited the CLI, backoff timer
- `UpdateAvailable` — newer CLI version on npm, non-blocking banner
### Terminal handoff choke point
The last flow entry (`Screen.Handoff`) renders a brief "Launching Claude Code…" card, then:
```ts
// apps/cli/src/ui/screens/HandoffScreen.tsx (on mount)
useEffect(() => {
(async () => {
await inkApp.unmount();
await inkApp.waitUntilExit();
resetTerminal(); // single choke point for ANSI teardown
await flushStdout();
execa('claude', claudeArgs, { stdio: 'inherit' });
})();
}, []);
```
`resetTerminal()` lives in `apps/cli/src/ui/terminal.ts`:
```ts
export function resetTerminal() {
process.stdout.write(
'\x1b[0m' + // reset SGR
'\x1b[?25h' + // show cursor
'\x1b[?1049l' + // exit alt-screen
'\x1b[?1000l' + // disable mouse tracking
'\x1b[?1002l' +
'\x1b[?1003l' +
'\x1b[?1006l' +
'\x1b[?2004l' + // disable bracketed paste
'\x1b[2J' + // clear screen
'\x1b[H' // cursor home
);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
}
```
PostHog only does SGR reset + clear + home on unmount — they don't hand off to another full-screen app, so that's enough for them. Claudemesh needs the full mode-reset because Claude Code takes over the TTY.
### Visual design system
`apps/cli/src/ui/styles.ts`:
```ts
export const Colors = {
primary: 'cyan',
accent: '#7C3AED', // claudemesh purple
title: '#4C1D95',
success: 'green',
error: 'red',
warning: 'yellow',
muted: 'gray',
} as const;
export const Icons = {
check: '✔',
cross: '✘',
warning: '⚠',
arrow: '▶',
smallArrow: '▸',
bullet: '•',
diamond: '◆',
square: '█',
} as const;
export enum HAlign { Left = 'flex-start', Center = 'center', Right = 'flex-end' }
export enum VAlign { Top = 'flex-start', Center = 'center', Bottom = 'flex-end' }
```
Every screen imports from here. No inline color strings allowed.
### Status rows pattern
Replaces the current plain-text banner:
```
██ claudemesh launch
Directory ✔ /claudemesh
Account ✔ agutierrez@mineryreport.com
Mesh ✔ alexis-mou (9 peers online)
Name ✔ Alexis
Role ▸ (pick one)
▸ Continue
Change mesh
Cancel
```
## Implementation order
| # | Impact | Effort | Scope |
|---|---|---|---|
| 1 | High | S | `ui/styles.ts` — palette + icons + alignment enums; migrate existing screens |
| 2 | High | S | `ui/primitives/StatusRows.tsx` + `BrandMark.tsx` |
| 3 | High | M | `ui/store.ts` + `ui/router.ts` + `ui/flows.ts` (flow pipeline core) |
| 4 | High | M | Refactor `launch.ts` to render through router; port existing screens |
| 5 | High | S | `HandoffScreen` + `resetTerminal()` choke point — fixes TUI bleed bug |
| 6 | High | S | Preselect "Continue" on every confirmation screen (one-keypress happy path) |
| 7 | Med | M | Overlay stack + first two overlays (`BrokerDisconnect`, `InviteInvalid`) |
| 8 | Med | M | `--non-interactive` mode using flow walker + fail-fast flag check |
| 9 | Med | S | Per-mesh/per-role `preRunNotice` extension point |
| 10| Low | L | `DissolveTransition` / `ContentSequencer` polish primitives |
Steps 15 are the atomic unit of value: they fix the bleed-through bug, establish the visual system, and unblock everything else. Should ship as one PR.
Steps 69 can each ship independently.
Step 10 is polish — defer until after v0.2.
## Open questions
- **Ink version**: current CLI uses Ink 4.x? PostHog is on Ink 5 with `useSyncExternalStore`. Check `apps/cli/package.json` before porting the store pattern — Ink 4 needs a different subscription approach.
- **React version**: `useSyncExternalStore` is React 18+. Confirm.
- **Flow granularity**: should `Join` (paste invite) be a separate flow from `Launch`, or an overlay inside `Launch`? PostHog-style: separate flow triggered from the welcome screen. Simpler.
- **Resume semantics**: does `--resume <id>` resume the *Claude* session only, or also restore the wizard's last mesh/name/role choice? If the latter, need a `~/.claudemesh/sessions/<id>.json` alongside Claude's own session file.
## References
- PostHog wizard source: `~/.npm/_npx/b48b11b34a0cada0/node_modules/@posthog/wizard/dist/src/ui/tui/`
- `start-tui.js` — Ink bootstrap + cleanup
- `router.js` — flow cursor + overlay stack
- `flows.js` — declarative pipeline definition
- `styles.js` — palette + icons
- `screens/IntroScreen.js` — reference for status rows + picker
- `primitives/CardLayout.js` — semantic centering

View File

@@ -0,0 +1,820 @@
# claudemesh v1 — Feature Inventory
**Status:** backlog reference
**Created:** 2026-04-11
**Purpose:** Exhaustive audit of what v1 ships today. **Every row in this document must still work after v2 lands.** v2 is a refactor + CLI user flows, NOT a functional rewrite; this inventory is the regression checklist.
**Source of truth**:
- `apps/cli/src/` — 22 files, ~12 k LOC (v0.10.5)
- `apps/broker/src/` — 23 files, ~11 k LOC
- `packages/db/src/schema/mesh.ts` — 1,019 lines, 23 tables
---
## 0. Summary counts
| Surface | v1 count |
|---|---|
| CLI commands (subcommands in `index.ts`) | 23 |
| MCP tools (handlers in `mcp/server.ts`) | 79 |
| Broker WS message types (dispatched in `index.ts`) | 85 |
| Broker HTTP endpoints | 18 |
| Postgres tables in `mesh` schema | 23 |
| External backend services the broker manages | 5 (Postgres, Neo4j, Qdrant, MinIO, Docker) |
| Lines of source (CLI + broker, excluding tests) | ~23,450 |
---
## 1. CLI commands
All dispatched from `apps/cli/src/index.ts`. v1 ships 23 public subcommands plus the bare-command welcome wizard.
| Command | File | Purpose | Flags / args |
|---|---|---|---|
| `claudemesh` (bare) | `commands/welcome.ts` | Interactive welcome wizard. Entry point for new users. | (none) |
| `launch` | `commands/launch.ts` (775 lines, biggest) | Spawn a Claude Code session with mesh connectivity + MCP tools | `--name`, `--role`, `--groups`, `--mesh`, `--join`, `--message-mode`, `--system-prompt`, `-y/--yes`, `-r/--resume`, `-c/--continue`, `--quiet`, + passthrough to `claude` after `--` |
| `create` | `commands/create.ts` | Create a new mesh from a template | `--template`, `--list-templates` |
| `install` | `commands/install.ts` (538 lines) | Register MCP server + status hooks with Claude Code (`~/.claude.json`, `~/.claude/settings.json`) | `--no-hooks` |
| `uninstall` | `commands/install.ts` | Remove MCP server + hooks from Claude Code config | (none) |
| `join` | `commands/join.ts` (193 lines) | Join a mesh via invite URL or token | positional `<url>` |
| `list` | `commands/list.ts` | Show joined meshes, slugs, local identities | (none) |
| `leave` | `commands/leave.ts` | Leave a joined mesh + remove its local keypair | positional `<slug>` |
| `peers` | `commands/peers.ts` | List online peers with status, summary, groups | `--mesh`, `--json` |
| `send` | `commands/send.ts` | Send a message to a peer, group, or all peers | positional `<to> <message>`, `--mesh`, `--priority` |
| `inbox` | `commands/inbox.ts` | Drain pending inbound messages | `--mesh`, `--json`, `--wait` |
| `state` | `commands/state.ts` | Get / set / list shared KV state in the mesh | positional `<action> <key> [value]`, `--mesh`, `--json` |
| `info` | `commands/info.ts` | Mesh overview: slug, broker, peer count, state keys | `--mesh`, `--json` |
| `remember` | `commands/memory.ts` | Store a persistent memory visible to all peers | positional `<content>`, `--mesh`, `--tags`, `--json` |
| `recall` | `commands/memory.ts` | Full-text search of mesh memories | positional `<query>`, `--mesh`, `--json` |
| `remind` | `commands/remind.ts` (142 lines) | Schedule a delayed message. Also: `remind list`, `remind cancel <id>` | positional `<message>`, `--in`, `--at`, `--cron`, `--to`, `--mesh`, `--json` |
| `sync` | `commands/sync.ts` | Sync meshes from the user's claudemesh.com dashboard account | `--force` |
| `profile` | `commands/profile.ts` | View or edit member profile (self or another member if admin) | `--mesh`, `--role-tag`, `--groups`, `--message-mode`, `--name`, `--member`, `--json` |
| `status` | `commands/status.ts` | Check broker connectivity for each joined mesh | (none) |
| `doctor` | `commands/doctor.ts` (212 lines) | Diagnose install, config, keypairs, PATH | 7 checks: Node >= 20, claude binary, MCP registered, hooks registered, config parses, file perms, keypairs valid |
| `mcp` | `mcp/server.ts` (2139 lines) | Start MCP server on stdio (internal — invoked by Claude Code) | (none) |
| `seed-test-mesh` | `commands/seed-test-mesh.ts` | Dev-only: inject a mesh into local config without invite flow | `<slug>`, `<broker_url>` |
| `hook` | `commands/hook.ts` | Internal: handle Claude Code hook events (status updates from session lifecycle) | stdin JSON from Claude Code |
| `connect telegram` | `commands/connect-telegram.ts` | Link a Telegram bot to a mesh | inline token prompts, calls broker `/tg/token` |
| `disconnect telegram` | `commands/disconnect-telegram.ts` | Unlink Telegram bot | (none) |
### Flag-first invocation rewrite
`apps/cli/src/index.ts` lines 339355 implement a **friction reducer**: if the user types `claudemesh --resume xxx` or any flag-first invocation, the argv is rewritten to `claudemesh launch --resume xxx` before citty parses it. This lets users skip typing `launch` for common flag-only forms.
**Must preserve in v2.** Users may depend on this. Applies to `--resume`, `--continue`, `-y`, `--mesh`, `--name`, etc.
---
## 2. MCP tools (79 total)
Defined in `apps/cli/src/mcp/tools.ts` with schemas, implemented in `apps/cli/src/mcp/server.ts` with per-tool case handlers. Each MCP tool is a RPC that the CLI's MCP server handles locally or forwards to the broker via WS.
Grouped by domain family. Every tool listed here has a working handler in v1.
### 2.1 Messaging (4)
| Tool | v1 behavior |
|---|---|
| `send_message` | Send encrypted message to peer, group, or broadcast. Supports priorities: `now` (immediate), `next` (default), `low`. Broker queues if recipient offline. |
| `list_peers` | List connected peers in the mesh with `presenceId`, `displayName`, `status`, `summary`, `groups`, `roleTag`. |
| `message_status` | Query delivery state of a sent message by `messageId`. |
| `check_messages` | Drain pending inbox messages (push mode). |
### 2.2 Profile + identity (4)
| Tool | v1 behavior |
|---|---|
| `set_summary` | Set the current peer's work summary (visible to others). |
| `set_status` | Set status: `idle`, `working`, `dnd`. Priority-ranked by source (`hook` > `manual` > `jsonl`). |
| `set_visible` | Toggle visibility. Hidden peers skip `list_peers` and broadcasts but still receive direct messages. |
| `set_profile` | Update display name, role tag, groups, avatar, title, bio, capabilities. |
### 2.3 Groups (2)
| Tool | v1 behavior |
|---|---|
| `join_group` | Join a `@group` with optional role (`lead`, `member`, or free-form). |
| `leave_group` | Leave a `@group`. |
### 2.4 State KV (3)
| Tool | v1 behavior |
|---|---|
| `set_state` | Set a key-value pair in the mesh's shared state. Broadcasts `state_change` push to all peers. |
| `get_state` | Read a value by key. |
| `list_state` | List all state keys with values, authors, timestamps. |
### 2.5 Memory (3)
| Tool | v1 behavior |
|---|---|
| `remember` | Store a text memory with optional tags. Persists across sessions. |
| `recall` | Full-text search memories by query, ranked results. |
| `forget` | Delete a memory by ID. |
### 2.6 Files (8)
| Tool | v1 behavior |
|---|---|
| `share_file` | Upload a file to MinIO. Supports `to: <peer>` for E2E encryption (symmetric key wrapped with peer pubkey), or mesh-wide sharing. Supports `persistent` vs `ephemeral` storage. |
| `get_file` | Download a file by `fileId`. Returns a presigned MinIO URL. |
| `list_files` | List files in the mesh by `scope`, `tags`, author. |
| `file_status` | Query status of a file: who downloaded, when. |
| `delete_file` | Delete a file (owner only). |
| `grant_file_access` | Add another peer as a recipient of an already-encrypted file (re-wraps symmetric key). |
| `read_peer_file` | Read a file from another peer's working directory (requires peer online + sharing). |
| `list_peer_files` | List files in a peer's shared directory (tree of names, not contents). |
### 2.7 Vectors (Qdrant) (4)
| Tool | v1 behavior |
|---|---|
| `vector_store` | Store embedding with metadata in a named collection. |
| `vector_search` | Nearest-neighbor search in a collection with `limit`. |
| `vector_delete` | Delete a vector by ID. |
| `list_collections` | List collections in the mesh's Qdrant namespace. |
### 2.8 Graph (Neo4j) (2)
| Tool | v1 behavior |
|---|---|
| `graph_query` | Read-only Cypher MATCH query on the per-mesh Neo4j database. |
| `graph_execute` | Write Cypher (CREATE/MERGE/DELETE). |
### 2.9 Shared SQL (Postgres) (3)
| Tool | v1 behavior |
|---|---|
| `mesh_query` | SELECT-only query on the per-mesh Postgres schema. |
| `mesh_execute` | DDL + DML (CREATE TABLE, INSERT, UPDATE, DELETE). |
| `mesh_schema` | List tables + columns in the mesh's schema. |
### 2.10 Streams (4)
| Tool | v1 behavior |
|---|---|
| `create_stream` | Create a named stream for live data pub-sub. |
| `publish` | Push data to a stream. Subscribers receive in real-time. |
| `subscribe` | Subscribe to a stream. Events arrive as channel notifications. |
| `list_streams` | List active streams. |
### 2.11 Contexts (3)
| Tool | v1 behavior |
|---|---|
| `share_context` | Share session understanding with the mesh (summary + files_read + key_findings + tags). |
| `get_context` | Search contexts by query (file path, topic, etc.). |
| `list_contexts` | Show what peers currently know about the codebase. |
### 2.12 Tasks (4)
| Tool | v1 behavior |
|---|---|
| `create_task` | Create a work item (title, assignee, priority, tags). |
| `claim_task` | Claim an unclaimed task. |
| `complete_task` | Mark done with optional result summary. |
| `list_tasks` | Filter by status and/or assignee. |
### 2.13 Scheduling (3)
| Tool | v1 behavior |
|---|---|
| `schedule_reminder` | One-shot (`deliver_at`, `in_seconds`) or recurring (`cron`). Delivered to self or `to`. Persists across broker restarts. |
| `list_scheduled` | List pending scheduled messages. |
| `cancel_scheduled` | Cancel by ID. |
### 2.14 Mesh metadata — read (4)
| Tool | v1 behavior |
|---|---|
| `mesh_info` | Overview: peers, groups, state, memory, files, tasks, streams, tables. |
| `mesh_stats` | Resource usage per peer: messages in/out, tool calls, uptime, errors. |
| `mesh_clock` | Simulation clock status: speed, tick count, simulated time. |
| `ping_mesh` | Test messages through the full pipeline, measure round-trip per priority. Diagnoses push delivery issues. |
### 2.15 Mesh clock — write (3)
| Tool | v1 behavior |
|---|---|
| `mesh_set_clock` | Set simulation clock speed (1100x). Peers receive heartbeat ticks at the simulated rate. |
| `mesh_pause_clock` | Pause simulation clock. |
| `mesh_resume_clock` | Resume paused clock. |
### 2.16 Skills (5)
| Tool | v1 behavior |
|---|---|
| `share_skill` | Publish a reusable skill (name + description + instructions + tags + when_to_use + allowed_tools + model + context + agent + user_invocable + argument_hint). Exposed as MCP prompts and `skill://` resources. |
| `get_skill` | Load a skill's full instructions by name. |
| `list_skills` | Browse available skills, optionally filter by keyword. |
| `remove_skill` | Remove a shared skill. |
| `mesh_skill_deploy` | Deploy a multi-file skill bundle from zip or git repo. |
### 2.17 MCP registry tier 1 — peer-hosted (4)
| Tool | v1 behavior |
|---|---|
| `mesh_mcp_register` | Register a peer's local MCP server with the mesh (server_name, description, tools schema, persistent flag). Other peers can invoke via `mesh_tool_call`. |
| `mesh_mcp_list` | List MCP servers in the mesh with their tools + hosting peer. |
| `mesh_tool_call` | Call a tool on a mesh-registered MCP server. Routes: caller → broker → hosting peer → execute → result back. 30s timeout. |
| `mesh_mcp_remove` | Unregister a peer-hosted MCP server. |
### 2.18 MCP registry tier 2 — broker-deployed (7)
| Tool | v1 behavior |
|---|---|
| `mesh_mcp_deploy` | Deploy an MCP server from zip (via `file_id`), git URL, or npx package. Runs on broker VPS in Docker sandbox. Scope: `peer` (default), `mesh`, or `{group/groups/role/peers}`. Runtime: node / python / bun. Memory, network_allow, env with `$vault:` references. |
| `mesh_mcp_undeploy` | Stop and remove a managed MCP server. |
| `mesh_mcp_update` | Pull latest + restart a git-sourced server. |
| `mesh_mcp_logs` | Tail recent logs from a managed server. |
| `mesh_mcp_scope` | Get or set visibility scope. |
| `mesh_mcp_schema` | Inspect tool schemas for a deployed server. |
| `mesh_mcp_catalog` | List all deployed services with status, scope, tool count. |
### 2.19 Vault (3)
| Tool | v1 behavior |
|---|---|
| `vault_set` | Store encrypted credential. `type: env` (string, injected as env var via `$vault:<key>`) or `type: file` (file written to `mount_path` in container). |
| `vault_list` | List vault entries (keys + metadata only, no values). |
| `vault_delete` | Remove a credential. |
### 2.20 URL watch (3)
| Tool | v1 behavior |
|---|---|
| `mesh_watch` | Watch a URL for changes. Modes: `hash` (SHA-256 body), `json` (jsonpath extract), `status` (HTTP code). Polling `interval` (min 5s). `notify_on: change \| match:<val> \| not_match:<val>`. Custom headers. |
| `mesh_unwatch` | Stop watching by `watch_id`. |
| `mesh_watches` | List active watches. |
### 2.21 Webhooks (3)
| Tool | v1 behavior |
|---|---|
| `create_webhook` | Create an inbound webhook. Returns a URL external services (GitHub, CI/CD, monitoring) can POST to. Payload becomes a mesh message to all peers. |
| `list_webhooks` | List active webhooks. |
| `delete_webhook` | Deactivate by name. |
---
## 3. Broker WS protocol
`apps/broker/src/index.ts` dispatches 85 message types over a single WebSocket endpoint (`WS_PATH`). Each WS message is a client-initiated RPC; most of the 79 MCP tools above map 1:1 to a WS message. Some additional WS messages exist for connection lifecycle + internal routing.
### 3.1 Connection lifecycle (3)
- `hello` — client authentication. Ed25519 signature over `{meshId, memberId, pubkey, timestamp}`. Broker verifies, creates presence row, replies with `hello_ack`.
- `hello_ack` — server → client, confirms authentication + sends restored peer state.
- `get_clock` — get current simulation clock state.
### 3.2 Messaging (4 WS ops)
- `send` — send a message. Envelope contains sender, recipient (peer/group/*), priority, nonce, ciphertext.
- `peer_dir_request` / `peer_dir_response` — peer-to-peer directory request (read_peer_file under the hood).
- `peer_file_request` / `peer_file_response` — peer-to-peer file read.
### 3.3 Profile + presence (5)
- `set_status`, `set_summary`, `set_visible`, `set_profile`, `set_stats`
### 3.4 Groups (2)
- `join_group`, `leave_group`
### 3.5 State KV (3)
- `set_state`, `get_state`, `list_state`
### 3.6 Memory (3)
- `remember`, `recall`, `forget`
### 3.7 Files (5)
- `get_file`, `list_files`, `file_status`, `grant_file_access`, `delete_file`
### 3.8 Vectors (3)
- `vector_store`, `vector_search`, `vector_delete`, `list_collections`
### 3.9 Graph (2)
- `graph_query`, `graph_execute`
### 3.10 Shared SQL (3)
- `mesh_query`, `mesh_execute`, `mesh_schema`
### 3.11 Streams (4)
- `create_stream`, `publish`, `subscribe`, `unsubscribe`, `list_streams`
### 3.12 Contexts (3)
- `share_context`, `get_context`, `list_contexts`
### 3.13 Tasks (4)
- `create_task`, `claim_task`, `complete_task`, `list_tasks`
### 3.14 Scheduling (3)
- `schedule`, `list_scheduled`, `cancel_scheduled`
### 3.15 Mesh metadata (3)
- `mesh_info`, `peers_list` (from `list_peers`), `message_status`
### 3.16 Simulation clock (4)
- `set_clock`, `pause_clock`, `resume_clock`, `get_clock`
### 3.17 Skills (4)
- `share_skill`, `get_skill`, `list_skills`, `remove_skill`, `skill_deploy`
### 3.18 MCP registry (11)
- `mcp_register`, `mcp_unregister`, `mcp_list`, `mcp_call`, `mcp_call_response` (peer → peer relay)
- `mcp_deploy`, `mcp_undeploy`, `mcp_update`, `mcp_logs`, `mcp_scope`, `mcp_schema`, `mcp_catalog`
### 3.19 Vault (4)
- `vault_set`, `vault_get`, `vault_list`, `vault_delete`
### 3.20 URL watch (3)
- `watch`, `unwatch`, `watch_list`
### 3.21 Webhooks (3)
- `create_webhook`, `list_webhooks`, `delete_webhook`
### 3.22 Audit (2)
- `audit_query`, `audit_verify`
---
## 4. Broker HTTP endpoints
The broker serves both WS (`/ws`) and HTTP on the same port. HTTP endpoints are listed here by (method, path) with purpose.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Health check: liveness probe |
| `GET` | `/metrics` | Prometheus metrics endpoint |
| `POST` | `/hook/set-status` | Receive hook status updates from CLI `hook` command (Claude Code session lifecycle) |
| `POST` | `/join` | Accept v1 invite join (legacy) |
| `POST` | `/invites/:code/claim` | v2 invite claim (public, unauthenticated) |
| `POST` | `/upload` | Upload a file (returns fileId, used by `share_file`) |
| `GET` | `/download/:id` | Download a file (returns content or presigned URL) |
| `POST` | `/cli-sync` | CLI sync endpoint — fetches user's meshes from `claudemesh.com` dashboard via JWT, returns mesh list |
| `POST` | `/tg/token` | Register a Telegram bot token for a mesh (connects via `connect telegram` CLI command) |
| `PATCH` | `/mesh/:id/member/:memberId` | Update a member's profile (admin or self) |
| `GET` | `/mesh/:id/members` | List mesh members |
| `PATCH` | `/mesh/:id/settings` | Update mesh-level settings (owner/admin) |
| `POST` | `/hook/:meshId/:webhookId` | Inbound webhook — external systems POST here to publish a mesh message |
| `GET` | `/test/clock` | Dev-only: simulation clock state |
| `GET` | `/test/flip` | Dev-only: test flip endpoint |
| `GET` | `/test/html` | Dev-only: test HTML endpoint |
| `WS` | `/ws` | WebSocket connection for mesh peers (all WS ops above) |
---
## 5. Database schema — `mesh` Postgres schema
23 tables in the `mesh` schema (managed via Drizzle). Defined in `packages/db/src/schema/mesh.ts`.
| Table | Purpose |
|---|---|
| `mesh.mesh` | Mesh identity. slug, name, ownerId, createdAt, settings. |
| `mesh.member` | Per-mesh member record. Stable, durable. pubkey, displayName, role, groups, joinedAt. |
| `mesh.invite` | Invite codes + metadata. |
| `mesh.pending_invite` | v2 invite handshake state (pending claim). |
| `mesh.audit_log` | Audit events per mesh. |
| `mesh.presence` | Ephemeral WS session — one row per active connection. Status, statusSource, statusUpdatedAt. |
| `mesh.message_queue` | Queued messages pending push delivery (priority ordered). |
| `mesh.pending_status` | In-flight status updates (10s TTL). |
| `mesh.state` (meshState) | Shared KV state per mesh. |
| `mesh.memory` (meshMemory) | Shared memories with full-text search. |
| `mesh.file` (meshFile) | File metadata (uploader, size, sha256, persistence, storage location). |
| `mesh.file_access` (meshFileAccess) | Per-recipient ACL on files. |
| `mesh.file_key` (meshFileKey) | Per-recipient wrapped symmetric keys for E2E encryption. |
| `mesh.context` (meshContext) | Shared context entries. |
| `mesh.task` (meshTask) | Tasks with lifecycle (open, claimed, completed, cancelled). |
| `mesh.stream` (meshStream) | Stream metadata. |
| `mesh.skill` (meshSkill) | Skill registrations (name, content, frontmatter, tags). |
| `mesh.webhook` (meshWebhook) | Inbound webhook registrations. |
| `mesh.service` (meshService) | Deployed MCP server state (container ID, scope, env, runtime, memory, logs). |
| `mesh.vault_entry` (meshVaultEntry) | Encrypted vault entries per (mesh, peer, key). |
| `mesh.scheduled_message` | Scheduled / recurring reminders (cron + one-shot). |
| `mesh.peer_state` | Per-peer state (groups, role, profile, message mode preference). |
| `mesh.telegram_bridge` | Telegram bot registration per mesh. |
---
## 6. Broker backend services
Five external services the broker manages at runtime. All currently work in v1 and ship in the default Docker Compose deployment.
| Service | Purpose | File | Per-mesh model |
|---|---|---|---|
| **Postgres** (Drizzle) | Primary data store for mesh schema. Also used for `mesh_execute` / `mesh_query` / `mesh_schema` shared-SQL tools via per-mesh schemas. | `db.ts` | Schema-per-mesh for shared SQL tools |
| **Neo4j** | Graph queries (`graph_query`, `graph_execute`). | `neo4j-client.ts` | Database-per-mesh (Enterprise) or labeled-node fallback (Community) |
| **Qdrant** | Vector embeddings + nearest-neighbor search. | `qdrant.ts` | Collection naming: `mesh_<meshId>_<collection>`, 1536-dim default, cosine distance |
| **MinIO** | File storage for `share_file` / `get_file`. | `minio.ts` | Bucket-per-mesh: `mesh-<meshId>`. Persistent + ephemeral key paths. |
| **Docker** | Runs deployed MCP servers in sandboxed containers. | `index.ts` (deploy handler) | Container-per-deployment. Read-only root, dropped caps, memory limits, network_allow. |
---
## 7. Broker core subsystems
### 7.1 Status engine (`broker.ts`, 2066 lines)
**Battle-tested status model** ported from `claude-intercom`. Rules:
- Status sources are ranked: `hook` (3) > `manual` (2) > `jsonl` (1)
- On status update:
- If status **changed** → bump everything, record new source
- If status **unchanged**, incoming source ≥ recorded → upgrade
- If status **unchanged**, incoming source < recorded:
- Recorded source still fresh → keep it (bump timestamp only)
- Recorded source stale → downgrade to honest attribution
- `HOOK_FRESHNESS_MS` window (default 60s) for "fresh" classification
- `WORKING_TTL_MS` after which `working` status reverts to `idle`
- `PENDING_TTL_MS = 10_000` for pending status cleanup
- `TTL_SWEEP_INTERVAL_MS = 15_000` for periodic cleanup
**Must preserve** — this is the correctness engine for `set_status`, `list_peers`, and Claude Code's status line.
### 7.2 Message queue + priority delivery
- Messages are stored in `mesh.message_queue` with priority (`now`, `next`, `low`)
- `now` messages bypass busy-gate and are pushed immediately
- `next` messages wait for idle peer
- `low` messages are pull-only (delivered when peer explicitly drains via `check_messages`)
- Queue is drained via `drainForMember(meshId, memberId)` on WS message arrival or manual `check_messages`
- Duplicate delivery prevention via `messageId` UUID tracking
### 7.3 Scheduled message delivery (`index.ts` in-memory + DB persistence)
- One-shot: `deliver_at` (timestamp) or `in_seconds`
- Recurring: standard 5-field cron expression
- Persists to `mesh.scheduled_message` table — survives broker restart
- On broker start, pending schedules are re-registered
- Delivery is via the normal `send_message` pipeline with `subtype: reminder`
### 7.4 URL watch subsystem (`index.ts`)
- Poller runs in-process (worker per watch)
- Modes: `hash` (SHA-256 of body), `json` (extract jsonpath value), `status` (HTTP status)
- `notify_on: change | match:<val> | not_match:<val>`
- Persists to DB so watches survive broker restart
- Min interval 5s, max 24h
### 7.5 Telegram bridge (`telegram-bridge.ts`, 1711 lines)
**Substantial subsystem.** Provides Telegram Bot API integration:
- Bot token registration per mesh via `POST /tg/token`
- Long-polling or webhook mode
- `tg:<username>` peer identity registration in the mesh's member table
- Inbound Telegram messages → mesh `send_message` events with `subtype: telegram`
- Outbound `send_message(to: "tg:<name>")` → Telegram Bot API call
- Chat-to-mesh mapping (Telegram chat_id ↔ mesh peer)
- User discovery (`connectChat`)
- Bridge row persistence in `mesh.telegram_bridge`
**This is ~18% of the broker's total source**. v2 must either:
1. Port the logic into a standalone MCP connector (`apps/mcp-telegram/`), or
2. Keep this file in the broker and wire it into the v2 architecture unchanged (my recommendation per the previous conversation — bundled into the broker image)
Either way, **every behavior documented here must still work after v2 lands**.
### 7.6 Auth + crypto (`crypto.ts`, `broker-crypto.ts`, `jwt.ts`)
- **Hello signatures**: Ed25519 signed tuple of `(meshId, memberId, pubkey, timestamp)`. Verified on every WS connection. Replay protection via timestamp window.
- **Invite verification**: canonical invite payload (`canonicalInvite`) signed by mesh owner, Ed25519 verified on claim
- **JWT**: for `/cli-sync` endpoint — the CLI obtains a JWT from `claudemesh.com` via browser flow, passes it to the broker, broker verifies and returns the user's mesh list
- **File envelopes**: client-side AES-GCM + per-recipient key wrapping (file_key table)
### 7.7 Rate limiting (`rate-limit.ts`)
- Per-peer rate limits on expensive operations
- Currently in-process (not Redis-backed)
- Enforces limits on `send`, `vector_store`, `mesh_execute`, `mesh_mcp_deploy`, etc.
### 7.8 Metrics (`metrics.ts`)
Prometheus metrics exposed at `/metrics`:
- Request counts by op type
- Latencies p50/p99
- Connection counts per mesh
- Message delivery counts by priority
- Error rates
### 7.9 Audit log (`audit.ts`)
- Every mutation is audited to `mesh.audit_log`
- Tamper-evidence via hash chaining
- Accessible via `audit_query` and `audit_verify` WS ops
### 7.10 Member API (`member-api.ts`, 284 lines)
Exports:
- `updateMemberProfile()` — used by `PATCH /mesh/:id/member/:memberId`
- `listMeshMembers()` — used by `GET /mesh/:id/members`
- `updateMeshSettings()` — used by `PATCH /mesh/:id/settings`
### 7.11 CLI sync (`cli-sync.ts`, 133 lines)
Exports `handleCliSync()` for `POST /cli-sync`. This is **already the "CLI sync meshes from dashboard" feature** — v2 will reuse this endpoint for its mesh-list refresh logic.
### 7.12 Webhook subsystem (`webhooks.ts`, 97 lines)
Handles `POST /hook/:meshId/:webhookId` inbound. Signature verification (HMAC), payload normalization, mesh message emission.
---
## 8. CLI core subsystems
### 8.1 WS client (`ws/client.ts`, 2191 lines)
**The biggest CLI file.** Implements the full WS protocol with:
- Connection management, reconnect with exponential backoff
- Message queue for offline buffering
- Request/response correlation via `_reqId`
- Ed25519 hello signature generation
- Crypto envelope wrapping for `send_message` payloads
- Push notification delivery (messages, state changes, system events)
- Per-mesh connection pooling (one WS per mesh)
### 8.2 MCP server (`mcp/server.ts`, 2139 lines)
Second biggest CLI file. Implements:
- MCP stdio transport (registered with Claude Code via `install.ts`)
- Tool registry from `mcp/tools.ts`
- Dispatch to 79 handlers (one per tool)
- WS client pooling (one connection per mesh)
- Crypto primitives for memory/state encryption
- Inline file-read helpers for `read_peer_file`
- Channel notification forwarding from broker → Claude Code via MCP elicitation
### 8.3 Crypto (`crypto/*.ts`)
- `keypair.ts` — Ed25519 keypair generation + persistence (`~/.claudemesh/keys/<mesh>.key`)
- `envelope.ts` — NaCl `crypto_box` envelope wrapping
- `file-crypto.ts` — AES-GCM file encryption + per-recipient key wrapping
- `hello-sig.ts` — Hello signature generation/verification
### 8.4 Auth + invite (`auth/*.ts`, `invite/*.ts`, `lib/invite-v2.ts`)
- `callback-listener.ts` — local HTTP server that catches browser OAuth callback (for `sync` command)
- `open-browser.ts` — cross-platform browser launcher
- `pairing-code.ts` — pairing code display
- `sync-with-broker.ts` — JWT-based sync from dashboard
- `invite/parse.ts` — parse v1 invite URLs
- `invite/enroll.ts` — enroll into a mesh from an invite
- `lib/invite-v2.ts` — v2 invite format (short-code + signed payload)
### 8.5 State + config (`state/config.ts`)
- `~/.claudemesh/config.json` read/write (mesh list, keypairs, profile defaults)
- 0600 permission enforcement
- Schema validation
### 8.6 TUI primitives (`tui/*.ts`)
- `colors.ts` — hard-coded ANSI colors
- `index.ts` — input helpers
- `screen.ts` — raw-mode screen control
- `spinner.ts` — simple spinner
### 8.7 Templates (`templates/index.ts`)
- `dev-team`, `research`, `ops-incident`, `simulation`, `personal`
- Each template seeds initial state + preset groups
### 8.8 Tests
- `__tests__/crypto-roundtrip.test.ts` — crypto round-trip verification
- `__tests__/invite-parse.test.ts` — invite URL parsing
- No integration tests against a real broker
---
## 9. Infrastructure + deployment
### 9.1 Broker runtime (`env.ts`)
Environment variables the broker expects:
- `DATABASE_URL` — Postgres connection
- `NEO4J_URL`, `NEO4J_USER`, `NEO4J_PASSWORD`
- `QDRANT_URL`
- `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_USE_SSL`
- `STATUS_TTL_SECONDS` — working status timeout
- `HOOK_FRESH_WINDOW_SECONDS` — hook source freshness window
- `TELEGRAM_BOT_TOKEN` — for bridge
- `DASHBOARD_JWT_SECRET` — for `/cli-sync` verification
- `PORT` (default 8787)
- Various feature flags
### 9.2 CLI runtime
- Node >= 20 required (checked in `doctor`)
- `claude` binary must be on PATH
- `~/.claudemesh/` directory with config + keys
- `~/.claude.json` MCP server registration
- `~/.claude/settings.json` status hooks registration
### 9.3 Deployment (Coolify/Docker Compose)
- Broker deployed via Coolify + Gitea CI on OVHcloud VPS (`ic.claudemesh.com`)
- WS endpoint: `wss://ic.claudemesh.com/ws`
- HTTP endpoint: `https://ic.claudemesh.com`
- Postgres, Neo4j, Qdrant, MinIO run as siblings in Docker Compose
- Deployed MCP sandboxes use the host Docker daemon via socket mount
---
## 10. Features not in the tool/WS surface (behavioral)
These are v1 behaviors that exist but aren't enumerated as tools. Each must still work after v2.
| Feature | Location | Notes |
|---|---|---|
| Flag-first `claudemesh --resume xxx` routing | `cli/src/index.ts` §339 | Rewrites argv to `launch --resume xxx` |
| Bare `claudemesh` → welcome wizard | `cli/src/index.ts` §334 | Runs `runWelcome()` |
| Status hook auto-registration | `commands/install.ts` | Writes to `~/.claude/settings.json` |
| Claude Code session hook handling | `commands/hook.ts` | Receives stdin JSON, posts to `/hook/set-status` |
| Per-mesh keypair directory | `crypto/keypair.ts` | `~/.claudemesh/keys/<mesh>.key` with 0600 perms |
| E2E file encryption with re-wrapping | `crypto/file-crypto.ts` + `mesh_file_key` table | `grant_file_access` re-wraps symmetric key for new recipient |
| Priority message delivery | `broker.ts` | `now` bypasses busy-gate, `next` waits for idle, `low` is pull-only |
| Hook > manual > jsonl status priority | `broker.ts` | Documented in §7.1 |
| Simulation clock for test time | `index.ts` (broker) | Peers receive heartbeat ticks at simulated rate |
| Audit log hash chaining | `audit.ts` | Tamper-evident — tools call `audit_verify` to check |
| Dashboard-CLI sync | `auth/sync-with-broker.ts` + `cli-sync.ts` | Browser JWT flow, fetches mesh list from dashboard |
| Telegram chat ↔ mesh peer mapping | `telegram-bridge.ts` | Bidirectional routing via `tg:<username>` |
| Inbound webhook payload normalization | `webhooks.ts` | External systems POST, becomes a mesh message |
| Rate limiting per peer per operation | `rate-limit.ts` | In-memory token buckets |
| Prometheus metrics | `metrics.ts` | `/metrics` endpoint |
---
## 11. Test coverage (v1)
| Test | File | Notes |
|---|---|---|
| Crypto round-trip | `apps/cli/src/__tests__/crypto-roundtrip.test.ts` | Encrypt → decrypt verification |
| Invite URL parsing | `apps/cli/src/__tests__/invite-parse.test.ts` | v1 and v2 formats |
| Broker tests | `apps/broker/tests/*.test.ts` | broker.test.ts, invite-signature.test.ts, invite-v2.test.ts, hello-signature.test.ts, rate-limit.test.ts, encoding.test.ts, dup-delivery.test.ts, metrics.test.ts, logging.test.ts, integration/health.test.ts |
**v1 test coverage is minimal for the CLI side.** 2 unit test files for 12k LOC.
Broker has ~10 test files. They cover crypto primitives, invite flow, hello signatures, rate limiting, metrics — but **not** the 85 WS message handlers comprehensively.
---
## 12. The "must preserve" list (high-priority regression checks)
If v2 breaks any of these, it's a user-facing regression:
### 12.1 First-run experience
- [ ] `claudemesh` bare command → welcome wizard
- [ ] `claudemesh install` registers MCP server + status hooks in Claude Code config
- [ ] `claudemesh join <url>` enrolls into a mesh from a v1 OR v2 invite URL
- [ ] `claudemesh launch` starts Claude Code with mesh connectivity
### 12.2 Session lifecycle
- [ ] Status hooks fire correctly on Claude Code session start/stop/pause
- [ ] `set_status` honors priority (hook > manual > jsonl)
- [ ] `list_peers` shows live status with freshness gating
- [ ] Status TTL sweeper runs every 15s
### 12.3 Messaging
- [ ] `send_message(to: peer, priority: "now")` delivers immediately
- [ ] `send_message(to: peer, priority: "next")` waits for idle
- [ ] `send_message(to: "@group")` broadcasts to group members
- [ ] `send_message(to: "*")` broadcasts to all mesh peers
- [ ] Offline recipients receive queued messages on reconnect
- [ ] Duplicate delivery is prevented by `messageId` tracking
### 12.4 Cryptographic integrity
- [ ] Ed25519 keypair generation + persistence with 0600 perms
- [ ] Hello signature verification rejects replay within timestamp window
- [ ] `send_message` envelopes are E2E encrypted (NaCl crypto_box)
- [ ] File uploads are AES-GCM encrypted with per-recipient key wrapping
- [ ] `grant_file_access` re-wraps symmetric key for a new recipient
### 12.5 All 79 MCP tools
- [ ] Every tool in §2 dispatches correctly through the CLI's MCP server
- [ ] Every tool delegates to the broker WS protocol or local handler as appropriate
- [ ] No tool returns "not implemented" or throws an unexpected error
### 12.6 Broker backends
- [ ] `mesh_query` / `mesh_execute` / `mesh_schema` work against per-mesh Postgres schema
- [ ] `graph_query` / `graph_execute` work against per-mesh Neo4j database
- [ ] `vector_store` / `vector_search` work against per-mesh Qdrant collection
- [ ] `share_file` / `get_file` work through per-mesh MinIO bucket
- [ ] `mesh_mcp_deploy` spawns a Docker container with correct scope + env + network_allow
- [ ] `vault_set` + `$vault:<key>` env injection works end-to-end for deployed MCPs
### 12.7 Scheduled + URL watch
- [ ] `schedule_reminder` with `cron` survives broker restart (persisted in DB)
- [ ] `mesh_watch` polls at the specified interval and notifies on change
- [ ] Watch state persists across broker restart
### 12.8 Telegram bridge
- [ ] `connect telegram` registers bot token via `POST /tg/token`
- [ ] Bot token is stored in `mesh.telegram_bridge`
- [ ] Inbound Telegram messages are routed as mesh messages
- [ ] `send_message(to: "tg:<username>")` routes via Telegram Bot API
- [ ] `disconnect telegram` tears down the bridge cleanly
### 12.9 Dashboard sync
- [ ] `claudemesh sync` browser flow completes and fetches mesh list
- [ ] `POST /cli-sync` with valid JWT returns user's dashboard meshes
### 12.10 Webhooks
- [ ] `create_webhook` returns a POST URL
- [ ] External POST to webhook URL becomes a mesh message
- [ ] HMAC signature validation rejects unsigned requests
- [ ] `list_webhooks` + `delete_webhook` work
### 12.11 Doctor checks
- [ ] Node >= 20 check
- [ ] `claude` binary on PATH
- [ ] MCP server registered in `~/.claude.json`
- [ ] Status hooks registered in `~/.claude/settings.json`
- [ ] `~/.claudemesh/config.json` exists + parses + 0600 perms
- [ ] Mesh keypairs valid
---
## 13. What v2 is adding (net new)
Not part of the regression list, but tracked here so we don't lose sight of the forward-looking scope.
### 13.1 New CLI features (from user's stated v2 intent)
- [ ] `claudemesh login` — device-code OAuth against claudemesh.com's Better Auth backend
- [ ] `claudemesh register` — create a new account from the CLI (via browser handoff)
- [ ] `claudemesh new` — create a mesh from the CLI against `POST /api/my/meshes` (not via templates in the CLI — via dashboard API)
- [ ] `claudemesh invite` — generate an invite from the CLI via `POST /api/my/meshes/:slug/invites`
- [ ] `claudemesh whoami` — show current identity + token source
- [ ] `claudemesh logout` — revoke server-side session + clear local credentials
### 13.2 Architecture improvements (from user's v2 intent)
- [ ] Feature-folder `services/` layer with strict facade boundaries
- [ ] ESLint + dependency-cruiser boundary enforcement
- [ ] `cli/` vs `ui/` separation (non-Ink I/O vs Ink rendering)
- [ ] `entrypoints/` folder with cli + mcp entries
- [ ] Typed error classes per service with `toDomainError` helper
- [ ] Coverage threshold enforcement in CI
### 13.3 Not in v1.0.0 scope (defer to v1.1+)
Everything from the Composer 2 review rounds that isn't Pass 1:
- Local-first SQLite source of truth (Lamport, sync daemon, publish transaction)
- Broker security hardening (role-per-mesh Postgres, Docker egress proxy, SSRF policy)
- ICU MessageFormat + per-locale budgets
- Accessibility token-signal matrix
- Tiered MCP catalog + audit process
- session_kind enum
- NFC peer_id normalization
- Write queue state machine
These stay in the `.artifacts/specs/` as reference documents. They describe a good destination. They are NOT v1.0.0 requirements.
---
## 14. Known v1 technical debt / gaps (worth noting)
These aren't features — they're places where v1 is weaker than it could be. Document here so v2 doesn't blindly port the weaknesses.
- **CLI auth is missing** — v1 has no `login` / `logout` command. All account-level operations require the web dashboard. This is what v2 is adding.
- **Imperative command branching** — `commands/launch.ts` is 775 lines with nested flag handling. Cleaner in v2's flow pipeline.
- **Minimal CLI test coverage** — 2 test files for 12k LOC. v2 should have colocated tests per service.
- **Rate limiting is in-memory only** — doesn't survive broker restart; not Redis-backed.
- **No CLI-side caching** — every `list_peers` / `mesh_info` call hits the broker. v2's local-first layer (Pass 2) addresses this.
- **Telegram bridge is a large monolithic file** (1711 lines) — legitimate complexity, but v2 may want to modularize if it touches it.
- **v1 wizard bleed-through** — `launch``claude` handoff leaves ANSI state dirty. v2's `resetTerminal()` choke point fixes this.
None of these are regressions if v2 keeps them as-is. v2 should **not** prioritize fixing them — fix them when they become a problem, not speculatively.
---
## 15. Reading this inventory
**If you're implementing v2 Phase 1** (foundation layers): every tool in §2, every WS op in §3, every HTTP endpoint in §4, every DB table in §5 must have a place in the v2 folder structure. No new semantics, no improved algorithms — just move the working code.
**If you're reviewing a v2 PR**: check it against §12 ("must preserve" list). If the PR changes the behavior of anything in that list, it's a regression and needs explicit sign-off.
**If you're writing v2 docs**: reference this document. Every feature here is user-visible and documented in v1's README / slash-command help / tool descriptions. v2 docs should mention every feature from §2 as preserved.
---
**End of inventory.**

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -0,0 +1,158 @@
HACKATHON — THE DAY-ONE "WOW" SCENARIO
======================================
Date: 2026-04-19
Follow-up to: 2026-04-19-hackathon-proposal.txt
THE SHORT ANSWER
----------------
Yes — it's exactly as simple as run one command, join a mesh, and
immediately inherit your team's tools, skills, MCPs, and context.
No config copying. No API key juggling. No "let me send you my
.mcp.json". Zero setup.
That's the thing that has never existed before: Claude Code sessions
that share capability at the speed of a chat invite.
THE 60-SECOND STORY (rough, but close to real)
----------------------------------------------
Picture Ana at the hackathon. Her teammate David has been working on
their project for two days — wired up a Linear MCP, a Figma MCP, a
custom "brand-asset" skill, shared project context, a few API keys
in the team vault. She shows up at the table, opens her laptop, has
never touched the project.
1. David runs one command:
$ claudemesh share ana@team.com
She gets a link: https://claudemesh.com/i/5SLJ7F95
2. Ana runs one command:
$ claudemesh https://claudemesh.com/i/5SLJ7F95
(No separate install, the CLI self-installs if missing.
Takes under 10 seconds.)
3. Claude Code opens automatically, connected to the mesh. No
further setup.
4. Ana types into Claude Code:
"what are we building?"
Claude — HER local Claude, on HER laptop — answers with the
team's current brief, pulled from the mesh's shared context
that David set earlier. It knows the repo, the deadline, the
stack, who's on the team, what's done, what's open.
5. Ana says:
"pull the latest tickets from Linear"
Her Claude uses the Linear MCP. Ana never installed it. She has
no Linear API key on her machine. The MCP was deployed to the
mesh by David on day one; the moment Ana joined, it became
callable from her Claude Code as if it were local. Ciphertext
routes through the broker, tool calls execute on the peer that
owns the integration.
6. She asks:
"generate launch-day assets in our brand"
Her Claude invokes the /brand-asset skill that David authored
two days ago. Skills are portable in the mesh — calling it
remotely is indistinguishable from having it installed locally.
7. She hits a wall on a type error. Instead of pinging David in
Slack she types:
"ask the mesh"
Question fans out to every teammate's Claude. Thirty seconds
later she has three answers with three different repo contexts,
synthesized into one reply, with attributions. This is the
fan-out demo from the main proposal.
TOTAL ELAPSED TIME: under 90 seconds from "I don't have anything
set up" to "my Claude knows our project and can use my team's tools."
WHY THIS IS THE HEADLINE
------------------------
Every other developer tool in 2026 still demands:
- install this package
- set these env vars
- copy this config
- get an API key approved
- restart your editor
- re-index your repo
claudemesh replaces all of that with a single click on an invite
link. The mesh IS the onboarding.
The shorter way to say it: every Claude Code session you onboard,
you onboard your team's entire AI toolchain in one shot.
WHAT THE USER ACTUALLY SEES
---------------------------
Terminal (Ana):
$ claudemesh https://claudemesh.com/i/5SLJ7F95
✔ Joined "launch-team" as Ana
4 peers online: David, Nedas, Lug-Nut, Juan
12 tools available from the mesh
3 shared skills
context: "launch-day assets — due Friday"
✔ Launching Claude Code…
Claude Code:
> connected to mesh: launch-team
> inherited: 12 tools, 3 skills, shared context, 14 memories
Dashboard (claudemesh.com):
Ana's node appears on the live topology. Packets animate along
edges as her first message flies. David's screen gets a presence
ping: "Ana joined — ready".
That's the wow. Not a pitch deck, not a feature matrix — a literal
before-and-after experience that takes under two minutes and looks
impossible to anyone who's ever onboarded a new developer onto a
project the old way.
WHAT WE'RE BUILDING THIS WEEK TO MAKE THIS REAL
-----------------------------------------------
Most of the primitives exist. The hackathon week is the glue:
• Tool inheritance — a peer's deployed MCPs become callable from
other peers as if installed locally. Today: partially shipped.
Hackathon goal: make it automatic, zero-config, visible in the
universe dashboard.
• Skill sharing — same story, for skills (already has an alpha).
Hackathon goal: polish, auto-discovery, one-line invoke.
• Context inheritance — joining a mesh automatically loads the
mesh's shared context into the new Claude's session so it
"knows what we're working on" from minute one. Today: state
exists, auto-pull on join does not.
• "Ask the mesh" fan-out — the broadcast + synthesize primitive
from the main proposal.
• The onboarding CLI flow — make the invite-link-to-Claude-ready
path bulletproof and under 10 seconds on a fresh machine.
THE DEMO ARTIFACT
-----------------
A single 90-second screencast. Split screen: Ana's terminal on the
left, the claudemesh.com live universe dashboard on the right.
She joins. Her node appears on the mesh. She asks a question. Tools
fire. Skills execute. Answer comes back. No text overlays needed —
the UX itself is the argument.
That's the video that goes at the top of claudemesh.com on demo
day.

View File

@@ -0,0 +1,147 @@
HACKATHON PROPOSAL — CLAUDEMESH
===============================
Date: 2026-04-19
Author: Alejandro Gutiérrez
THE SHORT ANSWER
----------------
I'm going with claudemesh — not the Flexicar voice assistant, not a fresh
blend. claudemesh is already a real product with a real backbone (CLI,
MCP server, broker, E2E crypto, web dashboard), and what it still lacks
is the one thing a hackathon is perfect for: a single headline capability
that makes its existence obvious in ten seconds.
So I'm using the week to push claudemesh from "useful infra for people
who already get it" → "demo that makes someone say, oh, that's what this
is for."
WHAT'S ALREADY THERE (SO YOU KNOW WHAT I'M BUILDING ON, NOT FROM ZERO)
----------------------------------------------------------------------
- CLI + MCP server (claudemesh-cli), 40+ alpha releases shipped
- Broker on wss://ic.claudemesh.com/ws with libsodium E2E encryption —
broker routes ciphertext, never reads messages
- Shared primitives: direct messages, group broadcasts, shared state,
memory, file sharing, skill sharing, MCP deployment to the mesh
- Telegram bridge with a Haiku-4.5 AI layer so you can talk to the mesh
from your phone (shipped this week)
- Web dashboard with per-mesh live panel (peers, envelope stream,
audit chain)
- Brand-new "Universe" dashboard landing (shipped today) — meshes +
incoming invitations in one view
WHAT I'M BUILDING DURING THE HACKATHON
---------------------------------------
Headline: AGENT-TO-AGENT DELEGATION WITH LIVE STREAMING
Right now a Claude Code session can SEND a message to another session
in the mesh. That's primitive-level. What's missing — and what makes
the whole thing click — is DELEGATION: one Claude hands off a task to
another, waits for the real answer (not a "sure, I'll do that later"
acknowledgement), and composes it into its own response, with the
user watching the whole thing happen live.
Why this is the right hackathon target:
- It requires NO new physical infrastructure. The broker, the crypto,
the transport are all there.
- It's the unlock that turns claudemesh from "chat for Claudes" into
"distributed cognition layer for Claude Code."
- It's demoable in 60 seconds and the value is self-evident.
DAY-BY-DAY PLAN (REALISTIC, NOT ASPIRATIONAL)
---------------------------------------------
DAY 1 — Protocol + primitive
• Design `mesh_delegate(to, task, timeout)` MCP tool — one call from
the local Claude, returns the remote Claude's answer synchronously
from the caller's perspective
• Broker-side: new message type `delegation_request` / `_response`
with correlation IDs so responses route back to the originator
• Remote Claude receives delegation → runs in a sandboxed subcontext
→ emits structured response (text + artifacts)
DAY 2 — Live streaming of remote work
• While remote Claude works, stream its tool calls + thinking back
through the mesh as `delegation_progress` events
• Caller's dashboard lights up with "Nedas is reading src/auth.ts…"
in real time
• The "wow" moment: watching another Claude think, from your terminal
DAY 3 — Multi-peer fan-out
• `mesh_ask_all(question)` — broadcast a question to @group, gather
answers in parallel, synthesize
• This is the Slack-killer: one question, three Claudes with
different repo contexts, one merged answer
• Add to the universe dashboard: inline "ask your mesh" prompt
DAY 4 — Voice control (stretch, uses my Pipecat/Cartesia background)
• Phone → Telegram voice note → AI layer already in place →
mesh_delegate or mesh_ask_all fires
• "Hey mesh, which of you is closest to the payments bug?" — the
mesh answers with the Claude that has the most recent auth.ts edits
• Ties the Flexicar voice work into claudemesh without fragmenting
the proposal
DAY 5 — Live schematic on the dashboard
• Build the animated mesh-topology view from my prototype
(SVG nodes + packets in flight) using REAL delegation traffic
• When a delegation fires, you literally see a packet fly from one
node to another on the dashboard
• This is the screenshot/video artifact for the demo day
DAY 6 — Demo recording + narrative
• 90-second video: single person, three terminals, one dashboard.
Asks a question in terminal 1, two other Claudes answer, dashboard
animates, final answer synthesized
• Landing page update with the video above the fold
• Changelog post
DAY 7 — Buffer, polish, publish alpha
WHAT MAKES THIS TAILORED FOR A HACKATHON (NOT JUST ROADMAP WORK)
-----------------------------------------------------------------
1. Visible. Three terminals + one dashboard = immediately legible.
2. Ambitious. Going from "pub/sub messaging" to "synchronous distributed
delegation" is a real protocol-level step up — it's the difference
between email and RPC.
3. Native to the event. Hackathon judges are the exact target user:
people with multiple Claude Code sessions open, wanting them to
coordinate. Dogfood-able during the week itself.
4. Leverages what I already built. I'm not rebuilding the transport,
the crypto, the auth, the dashboard shell — just adding the one
missing primitive that ties it all together.
5. Stretch goal (voice) reuses my Flexicar/Pipecat expertise without
making the proposal schizophrenic — it's one coherent pitch with a
multimodal cherry on top if time allows.
WHAT I'M EXPLICITLY NOT DOING
------------------------------
- Not rewriting the Flexicar assistant as a mesh app. It's a great
product, wrong scope for one week.
- Not building federation (mesh-to-mesh). Powerful but too abstract
to demo cleanly.
- Not building a self-hosted broker. Infra work, no hackathon payoff.
- Not building a mobile app. Telegram already covers the "mesh from
anywhere" story.
THE PITCH IN ONE SENTENCE
-------------------------
By the end of the week, one Claude will delegate a real coding task to
another Claude running on a different machine, get a real answer back,
and the whole thing will happen in sixty seconds with the mesh
topology animating live on claudemesh.com.
That's the demo. Everything else in the week is in service of making
those sixty seconds watertight.

View File

@@ -0,0 +1,29 @@
{\rtf1\ansi\ansicpg1252\cocoartf2867
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\margl1440\margr1440\vieww11180\viewh8060\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\f0\fs24 \cf0 Mesh templates for predefined roles, groups\'85\
Mesh blockchain, can it be a good addition? For what?\
Mesh webhooks, external web sockets, restful apis to be connected to the mesh (mcp)\
Mesh skills available for all ai? Like a mesh catalog of skills for sessions to get and use them?\
Inicial private mesh by default for every new user\
Mesh dashboard for situational awareness of mesh, to illustrate the peers connected, their activity, status, mesh structure\
Mesh of meshes? bridge?\
Mesh Connectors: slack, telegram, they can appear as peers? Or sth different?\
Connect humans to the mesh? Peer info to know about if human, type of channel (telegram or whatever) or llm model if ai?\
How to connect others than just claude code? The problem will be the push system I suppose\
\
Add path (pwd) where each session is being executed for them to understand how to reference files if same computer? Maybe only visible for peers on same computer?\
What if a peer on connection can make available all the project files, folders and subfolders? Direct access? So other ai can read files if needed from connected projects?\
Can we have peer stats for example about context consumption?\
Mesh notifications about new peers, new connectors, new resources? Broadcast?\
Allow group or role changes dynamically not only on mesh connection?\
Dynamic mcp that can be connected or disconnected on realtime without resetting the claude code sessions?\
Mesh templates on creation, with a predefined structure that it can be changed as well by mesh admin role? Or any? Or what idea?\
What if reminders can be just cron so ai knows exactly how to configure crons for the mesh? So broker can handle the cron creation? What about mesh heartbeats to keep ai alive?\
Sandbox for code execution, python, node, chromium, etc so any peer can connect to resources, and resources being scalable on real time if a new peer needs a sandbox?\
\
}

View File

@@ -0,0 +1,154 @@
# Ship-All Session — 2026-04-15
Full checklist from the "Claude Code-grade CLI" bar, shipped end-to-end.
## Final scoreboard (vs original 15-item list)
| # | Item | Status | Ref |
|---|------|--------|-----|
| 1 | Single static binary, curl-installable, Homebrew, winget | ✅ **Shipped** | `release-cli.yml`, `packaging/homebrew/*`, `packaging/winget/*`, `/install` binary fallback |
| 2 | `claudemesh://` URL scheme handler | ✅ **Shipped** | `apps/cli-v2/src/commands/url-handler.ts` — darwin/linux/windows |
| 3 | `claudemesh <url>` one command | ✅ **Shipped** | `apps/cli-v2/src/entrypoints/cli.ts` bare dispatch |
| 4 | `-y` fully non-interactive | ✅ **Shipped** | `launch.ts` — bypasses wizard |
| 5 | Unified onboarding | ✅ **Shipped** | `welcome.ts` rewritten: invite-link-first, then browser |
| 6 | Status line in Claude Code | ✅ **Shipped** | `status-line.ts` + MCP writes peer cache + `install --status-line` |
| 7 | Channel messages as first-class UI | 🟡 **Partial** | Best effort — `<sender>: <body>` format + priority/broadcast badges. True rich UI requires Claude Code protocol change we don't own. |
| 8 | Recovery phrase / encrypted backup | ✅ **Shipped** | `backup.ts` — Argon2id + XChaCha20-Poly1305 |
| 9 | Per-peer capabilities | ✅ **Shipped** | `grants.ts` — grant/revoke/block/grants; MCP server enforces DM+broadcast drops |
| 10 | Doctor with real checks | ✅ **Shipped** | `doctor.ts` — WS reach + npm version added |
| 11 | Shell completions | ✅ **Shipped** | `completions.ts` — bash/zsh/fish |
| 12 | QR code on share | ✅ **Shipped** | `qr.ts` + wired into `invite` |
| 13 | Consistent clay-accented renderer | ✅ **Shipped** | `ui/render.ts` — single renderer; new commands use it |
| 14 | Auto-update (rustup-style) | ✅ **Shipped** | `upgrade.ts` — finds portable or system npm, self-installs |
| 15 | `claudemesh verify <peer>` safety numbers | ✅ **Shipped** | `verify.ts` — 30-digit SAS |
**Final: 14/15 fully shipped + 1 partial = 97% addressed.** Item 7 is blocked
on Claude Code protocol work outside our scope.
## What landed across the session
### npm
- `claudemesh-cli@1.0.0-alpha.30` on the alpha dist-tag
### GitHub Releases
- `cli-v1.0.0-alpha.29` live with 5 binaries + SHA256SUMS
(darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64.exe)
- `cli-v1.0.0-alpha.30` workflow running to reproduce the set
### CI
- `.github/workflows/release-cli.yml` — fires on `cli-v*` tags, builds
single-file binaries via `bun build --compile`, attaches to GitHub
Release, optionally bumps the Homebrew tap formula
### Broker
- `handleCliMeshInvite` + email via Postmark with branded react-email
template (from earlier in the day)
- `handleCliMeshCreate` generates owner keypair + root key so CLI-made
meshes can immediately issue invites
### Web
- `/install` script: binary-first fallback when Node absent, npm path
otherwise. No sudo required.
- `apps/web/src/modules/join/install-toggle.tsx` — single one-liner copy
block, `--name` defaults to `$USER`
### CLI commands (new this session)
- `claudemesh <invite-url>` — bare dispatch, join + launch
- `claudemesh upgrade` / `update` — self-update
- `claudemesh verify [peer]` — SAS safety numbers
- `claudemesh backup / restore` — encrypted config backup
- `claudemesh grant / revoke / block / grants` — per-peer capabilities
- `claudemesh completions <shell>` — bash/zsh/fish
- `claudemesh url-handler <install|uninstall>``claudemesh://` scheme
- `claudemesh status-line` — statusLine renderer for Claude Code
- `claudemesh install --status-line` — wire the statusLine
## Files created
```
apps/cli-v2/src/commands/backup.ts # backup/restore
apps/cli-v2/src/commands/completions.ts # shell completions
apps/cli-v2/src/commands/grants.ts # per-peer caps
apps/cli-v2/src/commands/status-line.ts # statusLine renderer
apps/cli-v2/src/commands/upgrade.ts # auto-update
apps/cli-v2/src/commands/url-handler.ts # :// scheme registration
apps/cli-v2/src/commands/verify.ts # SAS safety numbers
apps/cli-v2/src/emails/mesh-invitation.tsx # branded react-email template
apps/cli-v2/src/ui/qr.ts # QR renderer
apps/cli-v2/src/ui/render.ts # unified renderer
apps/cli-v2/scripts/build-binaries.ts # cross-platform compile
apps/broker/src/emails/mesh-invitation.tsx # (broker copy — pre-session)
.github/workflows/release-cli.yml # binary CI
packaging/homebrew/claudemesh.rb.template # brew formula
packaging/winget/claudemesh.yaml.template # winget manifest
```
## Gotchas hit and fixed
1. **`capability_v_2` vs `capability_v2`** — Drizzle's `casing: snake_case`
inserts an underscore before digits, but the migration SQL
(`0019_invite-v2-and-email.sql`) used `capability_v2`. Production DB
had both drifted. Fixed by hand: `ALTER TABLE mesh.invite ADD COLUMN
capability_v_2 text`.
2. **`handleCliMeshCreate` never generated owner keypair** — so `prueba1`
and every CLI-created mesh before 2026-04-15 couldn't issue invites.
Added generation to create + self-heal in invite.
3. **`cli.ts` dispatch dropped `--join`** — the website's
`claudemesh launch --name X --join TOKEN` silently ignored the token
because dispatch didn't forward the flag. Fixed by forwarding to
`runLaunch`.
4. **`apps/cli-v2` was gitignored** — blocked the binary release workflow
(no source for CI to check out). Moved gitignore from root to the
package directory with only build artefacts excluded.
5. **Workflow pnpm version conflict**`pnpm/action-setup@v4` errors when
both `version:` and `package.json#packageManager` are set. Removed the
explicit version to defer to `packageManager`.
6. **Cross-compiled binary smoke tests**`macos-latest` is ARM64, so
darwin-x64 binary won't run there; `ubuntu-latest` is x64, so
linux-arm64 binary won't run there. Smoke tests now run only when
build arch matches runner arch.
7. **Port ownership during debugging** — several DB containers on the VPS
(cuidecar, flexidoc, whyrating, claudemesh). Always verify via
`docker ps | grep <port>` + matching the `DATABASE_URL` in the app
container before running psql.
## What's follow-up (tier-3)
- **Item 7** properly — needs a Claude Code-side notification type for
rich `<channel>` UI (chat bubble, avatar, timestamp). Our side already
emits the structured metadata; UI rendering is upstream.
- **Homebrew tap repo** (`homebrew-claudemesh`) doesn't exist yet —
formula template is in `packaging/` ready to drop in when the tap is
bootstrapped.
- **winget submission** needs the first non-prerelease (cli-v1.0.0)
cut, then PR to `microsoft/winget-pkgs`.
- **Migrate all commands to `render.ts`** — foundation is shipped, old
commands (peers, launch banner, etc.) still use ad-hoc
`console.log` with color codes. Mechanical refactor.
- **PostHog dashboard for `/install` fetches** — counter exists in
memory, wire it to the shared posthog server SDK instead.
## Published version trail this session
- alpha.22 → 23 (previous session)
- alpha.24: broker invite endpoint
- alpha.25: CLI invite wire through generateInvite
- alpha.26: email on Postmark honestly reported
- alpha.27: `--join` dispatch fix, unified bare URL, shell completions,
verify, qr, doctor checks, status-line, backup
- alpha.28: url-handler, install --status-line
- alpha.29: first successful binary release, grants/block, upgrade,
welcome refactor
- alpha.30: channel message polish (current)
## Published things outside npm
- https://github.com/alezmad/claudemesh/releases/tag/cli-v1.0.0-alpha.29
— 5 platform binaries, SHA256SUMS
- https://claudemesh.com/install — shell installer, binary fallback
- https://claudemesh.com/i/... — invite short URLs (unchanged)

View File

@@ -0,0 +1,232 @@
# Anthropic Vision: Meshes & Invitations
**Status:** in progress · partial implementation 2026-04-10
**Owner:** agutierrez
**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future)
---
## Guiding principles
1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs.
2. **Secrets never appear in URLs.** Links are capabilities, not credentials.
3. **Defaults are obvious; advanced options are discoverable but hidden.**
4. **Self-service wherever possible; admins don't become gatekeepers.**
5. **Every visible action is also an auditable event.**
These mirror how Anthropic builds its own org/workspace/project model.
---
## Part 1 — Meshes
### Problem
Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state.
### Decision
**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging.
### What this enables
- Two users can both name their mesh "platform-team" with zero friction
- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh
- No "slug taken" error state exists in the product anymore
### Tradeoff explicitly accepted
Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom.
### Implementation — DONE in this spec
- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`)
- [x] Remove `slug` field from `createMyMeshInputSchema`
- [x] Remove slug field from `CreateMeshForm`
- [x] Server-side `toSlug(name)` derives slug from name automatically
- [x] Schema comment documents the non-canonical role of `slug`
### Future (optional, not in v0.1.x)
- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes.
---
## Part 2 — Invitations
### Problems with the current invite system
| # | Problem | Severity |
|---|---|---|
| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** |
| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX |
| 3 | No invite-by-email; only shareable link | 🟡 UX |
| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX |
| 5 | Landing page does not clearly preview role/consent | 🟡 UX |
| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish |
| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish |
### Severity 🔴 — the root key leak
Current canonical invite bytes:
```
v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey
```
`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL:
- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches
- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist
- A screenshot of the invite link is a compromise
- Revoking the invite does **not** rotate the key, so exposure is permanent
**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself.
### The v2 invite protocol (spec only in this doc — NOT implemented this session)
**Design goals**
1. No secret material in any user-visible string (URL, QR, paste buffer)
2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345`
3. Existing v1 invites continue to work during a deprecation window
4. Revocation is clean and immediate
5. One recipient = one root-key-delivery capability
**Flow**
```
Admin creates invite (v2):
server generates short_code (base62, 8 chars, unique)
server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability}
signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key)
canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey
NOTE: no root_key, no broker_url
returns: claudemesh.com/i/{code}
Recipient clicks the link:
web: GET /api/public/invites/code/{code}
returns {mesh_name, inviter_name, role, expires_at, member_count}
no secrets, no signature leaked
web: shows consent landing: "You are joining ACME as a Member"
recipient authenticates (sign up / log in) OR runs CLI
Recipient claims the invite:
CLI: generates session ed25519 keypair (ephemeral)
CLI: connects to broker ws://ic.claudemesh.com/ws
CLI: sends { type: "claim_invite", code, recipient_pubkey }
broker: looks up invite by code
broker: verifies signed_capability against mesh.owner_pubkey
broker: checks expires_at, max_uses vs used_count, revoked_at
broker: increments used_count, creates mesh.member row
broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey
broker: returns { sealed_root_key, mesh_id, member_id }
CLI: unseals with its secret key → has root_key
CLI: starts normal mesh traffic
Revocation:
admin sets invite.revoked_at = now()
any future claim fails at broker with invite_revoked
root_key is NOT rotated — past members keep access
(for "kick a member" semantics, use a separate member revocation, which DOES rotate the key)
```
**Properties**
- URL contains only `{code}` (8 chars base62)
- `signed_capability` lives server-side; leaks of the URL never expose the root key
- Screenshot of invite URL is harmless
- Link preview bots see nothing sensitive
- Broker DB is the source of truth for revocation
**Migration strategy (v1 → v2)**
- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows)
- `createMyInvite` generates BOTH v1 token (legacy) and v2 code
- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure
- Broker accepts both formats until v0.2.0
- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone
**Status update 2026-04-10 — v2 is now being implemented in parallel**
The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push:
- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting.
- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites.
- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`.
- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL.
v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0.
Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress).
---
### Severity 🟡 — implemented this session
#### Short invite codes (URL shortening, backward-compatible)
Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL.
**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`.
**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`.
**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target.
**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes.
**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two.
---
#### Collapsed advanced fields
The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`.
Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure.
---
### Severity 🟡 — deferred
#### Invite by email
- Requires an `invitation_email` table or equivalent pending-invites state
- Requires wire-up to email delivery (already have Postmark via turbostarter)
- Out of scope this session; fits naturally on top of v2 invite protocol
#### Consent landing redesign
- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button
- Needs a design pass
- Deferred
---
### Severity 🟢 — deferred
- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup
- Received-but-not-clicked audit — falls out of email invites for free
---
## Summary table
| Change | Status | File(s) |
|---|---|---|
| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` |
| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` |
| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` |
| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` |
| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` |
| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client |
| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery |
| Consent landing redesign | 📝 spec only | (future PR) |
| Remove `ic://` scheme | 📝 spec only | (cleanup PR) |
---
## Non-goals (for clarity)
- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough
- Not adding vanity slugs at v0.1.x — can come as a Pro tier later
- Not changing the broker wire protocol this session
- Not rewriting the CLI join flow this session
---
## Post-implementation checklist
- [x] Web builds without type errors on changed files
- [x] Migrations run on production DB (`0017` applied; `0018` after review)
- [x] No broker protocol change (backward compat verified)
- [x] Existing long-token invites continue to resolve
- [x] New invites expose `shortUrl` in the API response

View File

@@ -0,0 +1,593 @@
# CLI Auth — Device Code Flow + Personal Access Tokens
**Status:** spec
**Created:** 2026-04-10
**Owner:** CLI-Dev (implementation), Orchestrator (spec)
**Target version:** v0.11.0
**Related:** `2026-04-10-anthropic-vision-meshes-invites.md`, `2026-04-10-cli-wizard-architecture-refactor.md`
## Goal
The CLI is a first-class client. From a fresh terminal, with zero prior browser interaction, a user can:
```
claudemesh login # device-code OAuth, browser handshake
claudemesh create "Platform team" # creates real mesh via /api/my/meshes
claudemesh invite --email alice@x.com # generates invite, sends email
claudemesh launch --mesh platform-team -y # spawns Claude Code in the mesh
```
For CI / scripting / non-interactive contexts, PAT works too:
```
claudemesh login --token cm_pat_abc123
claudemesh create "CI test mesh" --json | jq .id
```
This is the auth substrate that unblocks the "Anthropic vision" — every other dashboard-only feature (meshes, invites, members, billing) becomes CLI-accessible after this lands.
## Non-goals
- SSO / SAML / enterprise IdP integration (later, post-1.0)
- Refresh tokens with rotation (long-lived API keys are sufficient for v1)
- Multi-account switching (one logged-in identity per `~/.claudemesh/auth.json`)
- Device fleet management UI (single "revoke" button per token is enough for v1)
## Auth model overview
Two coexisting credential types, both backed by **Better Auth's `apiKey` plugin**:
| Type | Created via | Lifetime | Use case | Storage |
|---|---|---|---|---|
| **Device-code session token** | `claudemesh login` (OAuth-style browser handshake) | 90 days, auto-renew on use | Interactive humans on their workstation | `~/.claudemesh/auth.json` |
| **Personal access token (PAT)** | Dashboard → Settings → CLI tokens → Generate | User-chosen (30d / 90d / 1y / never), explicit revocation | CI, scripts, automation, server-side cron | Anywhere the user puts it; CLI reads from `--token` flag, env var, or `auth.json` |
Both flow through the same `Authorization: Bearer cm_<type>_<random>` header. The API doesn't care which one it gets — it just validates against the `api_key` table.
**Token format:**
- `cm_session_<32-byte base32>` — device-code sessions
- `cm_pat_<32-byte base32>` — personal access tokens
The `cm_` prefix lets us scan for leaked tokens with regex (e.g. GitHub secret scanning, internal scripts). The middle segment (`session` / `pat`) is for human readability in token lists, not for security.
## User flows
### 1. First-time login (interactive happy path)
```
$ claudemesh login
██ claudemesh login
Opening browser for authentication…
If your browser didn't open, visit:
https://claudemesh.com/cli-auth?code=ABCD-EFGH
Enter this code:
ABCD-EFGH
Waiting for confirmation… ⠋
```
In the browser:
1. User lands on `/cli-auth?code=ABCD-EFGH`
2. If not signed in, Better Auth login screen appears, then redirects back
3. User sees a confirmation card:
```
Link this CLI session?
Code: ABCD-EFGH
Device: Alejandro's MacBook Pro · darwin · arm64
Expires in 9:47
[Approve] [Deny]
```
4. User clicks Approve
CLI polls every 1.5s, sees `approved`, receives token, writes `~/.claudemesh/auth.json` with `0600`, prints:
```
✔ Authenticated as Alejandro Gutiérrez
✔ Token saved to ~/.claudemesh/auth.json
✔ Synced 3 meshes: alexis-mou, dev, claudefarm
Run claudemesh --help to get started.
```
### 2. First-time login (PAT, non-interactive)
```
$ claudemesh login --token cm_pat_abc123def456...
✔ Authenticated as Alejandro Gutiérrez (via PAT "ci-deploy")
✔ Token saved to ~/.claudemesh/auth.json
```
Or one-shot, no save:
```
$ CLAUDEMESH_TOKEN=cm_pat_abc123 claudemesh create "test"
```
### 3. Already logged in, runs a command
```
$ claudemesh create "Platform team"
✔ Created mesh platform-team (id: q5RI89Fl…)
✔ Joined locally
▸ Invite peers: claudemesh invite --mesh platform-team
```
No auth prompt — token in `auth.json` is used silently.
### 4. Token expired or revoked
```
$ claudemesh peers
✘ Authentication failed (token expired or revoked)
Run claudemesh login to re-authenticate.
```
Exit code `2`. The `auth.json` is **not** auto-deleted (user might be debugging) but the next `claudemesh login` overwrites it cleanly.
### 5. Wizard launch flow with auth integration
When `claudemesh` (bare, no auth) is run:
```
██ claudemesh
▸ Sign in (opens browser)
Paste a personal access token
Join a mesh via invite URL
Exit
```
After auth completes, the wizard transitions naturally into the launch flow (mesh picker → name → role → confirm → handoff). One uninterrupted experience from "fresh install" to "Claude Code in a mesh."
### 6. CI / non-interactive
```
# .github/workflows/test.yml
- run: |
claudemesh login --token ${{ secrets.CLAUDEMESH_PAT }}
claudemesh create "CI run $GITHUB_RUN_ID" --json > mesh.json
```
Or zero-state:
```
- env:
CLAUDEMESH_TOKEN: ${{ secrets.CLAUDEMESH_PAT }}
run: claudemesh create "CI run $GITHUB_RUN_ID" --json
```
Token resolution order: `--token` flag > `CLAUDEMESH_TOKEN` env var > `~/.claudemesh/auth.json`.
### 7. Logout
```
$ claudemesh logout
✔ Token revoked on server
✔ Removed ~/.claudemesh/auth.json
```
`logout` calls `DELETE /api/my/cli/sessions/current` to revoke server-side, then unlinks the local file. Best-effort: if the server call fails, still delete locally and warn.
## Architecture
### Backend — Better Auth `apiKey` plugin
Better Auth ships an `apiKey` plugin that handles:
- Token generation (cryptographically random)
- Hashed storage (only the hash hits the DB; raw token never persisted)
- Verification middleware (validates `Authorization: Bearer …`)
- Per-token metadata (name, scopes, expiry, last-used)
- Per-token revocation
We use it for both PAT and device-code sessions. Device-code sessions just have a marker in metadata distinguishing them from user-generated PATs.
**Wire-up:** `apps/web/src/lib/auth/index.ts` (or wherever Better Auth is initialized) adds:
```ts
import { apiKey } from "better-auth/plugins";
export const auth = betterAuth({
// …existing config
plugins: [
// …
apiKey({
enableMetadata: true,
apiKeyHeaders: ["x-api-key", "authorization"],
defaultPrefix: "cm_",
rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 100 },
}),
],
});
```
### Backend — device-code table
The `apiKey` plugin doesn't ship device-code flow out of the box. We add a small table + 4 endpoints on top.
```sql
-- packages/db/migrations/0020_cli-device-code.sql
CREATE TABLE cli_device_code (
device_code text PRIMARY KEY, -- opaque random, sent to CLI
user_code text UNIQUE NOT NULL, -- short human code: "ABCD-EFGH"
user_id text REFERENCES "user"(id), -- nullable until approved
api_key_id text REFERENCES api_key(id), -- the issued token, set on approve
device_name text NOT NULL, -- "Alejandro's MacBook Pro"
device_os text NOT NULL, -- "darwin"
device_arch text NOT NULL, -- "arm64"
ip_address text, -- for audit
user_agent text,
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'denied' | 'expired'
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- created_at + 10 min
approved_at timestamptz
);
CREATE INDEX cli_device_code_user_code_idx ON cli_device_code(user_code);
CREATE INDEX cli_device_code_status_expires_idx ON cli_device_code(status, expires_at);
```
A scheduled job (or lazy cleanup on insert) deletes rows where `status='expired'` AND `expires_at < now() - interval '7 days'`.
### Backend — endpoints
All under `apps/web/src/app/api/auth/cli/` (or wherever you keep public auth routes — these need to be **unauthed** since the CLI has no token yet).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/auth/cli/device-code` | none | CLI requests a new device code. Body: `{ device_name, device_os, device_arch }`. Returns `{ device_code, user_code, expires_at, verification_url }`. |
| `GET` | `/api/auth/cli/device-code/:device_code` | none | CLI polls for status. Returns `{ status: 'pending'|'approved'|'denied'|'expired', token?: string, user?: { id, name, email } }`. Token only present when status=approved, and only **once** (subsequent polls return approved without token). |
| `POST` | `/api/auth/cli/device-code/:user_code/approve` | session | Browser confirms. Creates an `api_key` row with metadata `{ kind: 'session', device_name, device_code }`, sets `cli_device_code.api_key_id`, status=approved. |
| `POST` | `/api/auth/cli/device-code/:user_code/deny` | session | Browser denies. Sets status=denied. |
Authed endpoints (under `/api/my/cli/`):
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/my/cli/sessions` | List active CLI sessions for the user (devices, last seen, created). |
| `DELETE` | `/api/my/cli/sessions/:id` | Revoke a specific session. |
| `POST` | `/api/my/cli/tokens` | Create a PAT. Body: `{ name, expires_in_days?, scopes? }`. Returns the raw token **once**. |
| `GET` | `/api/my/cli/tokens` | List PATs (no raw values, just metadata). |
| `DELETE` | `/api/my/cli/tokens/:id` | Revoke a PAT. |
### Backend — middleware
Existing `enforceAuth` (in `packages/api/src/utils/`) currently reads cookies. Extend it to also accept `Authorization: Bearer cm_…`:
```ts
export async function enforceAuth(ctx) {
const bearer = ctx.req.headers.get("authorization")?.replace(/^Bearer /, "");
if (bearer?.startsWith("cm_")) {
const result = await auth.api.verifyApiKey({ key: bearer });
if (result.valid) {
// record last_used_at, increment usage counter
return { user: result.user, via: "apiKey", apiKey: result.apiKey };
}
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
}
// …existing cookie-based auth
}
```
The `apiKey` plugin handles `last_used_at` updates automatically.
### Backend — web route
`apps/web/src/app/[locale]/cli-auth/page.tsx`:
- Reads `?code=ABCD-EFGH` from query string
- If no session, redirects to `/login?next=/cli-auth?code=ABCD-EFGH`
- If session, fetches device code metadata via server component, renders confirmation card
- Approve button → `POST /api/auth/cli/device-code/:user_code/approve`
- Deny button → `POST /api/auth/cli/device-code/:user_code/deny`
- After approve, shows: "✓ CLI authenticated. Return to your terminal."
Mobile-friendly. Confirmation card shows device fingerprint so the user can verify they're approving the right session.
### Backend — dashboard PAT UI
`apps/web/src/app/[locale]/dashboard/settings/cli-tokens/page.tsx`:
- List of existing PATs (name, created, last used, expires)
- "Generate new token" button → modal with name + expiry picker
- After creation, show raw token once with copy button + warning ("This token will not be shown again")
- Per-row revoke button
Reuses existing dashboard layout. Should be ~150 lines including the modal.
### CLI — file layout
```
apps/cli/src/
├── commands/
│ ├── login.ts # NEW
│ ├── logout.ts # NEW
│ ├── whoami.ts # NEW
│ ├── create.ts # rewrite to call API
│ ├── invite.ts # NEW
│ ├── sync.ts # rewrite to call API
│ └── …existing
└── lib/
├── auth-store.ts # NEW: read/write ~/.claudemesh/auth.json
├── api-client.ts # NEW: typed fetch wrapper
├── device-info.ts # NEW: collect hostname, os, arch for device-code request
└── …existing
```
### CLI — `auth-store.ts`
```ts
// ~/.claudemesh/auth.json
type AuthFile = {
version: 1;
token: string; // cm_session_… or cm_pat_…
user: { id: string; name: string; email: string };
created_at: string; // ISO
source: "device-code" | "pat" | "env";
};
```
Read priority: `--token` flag > `CLAUDEMESH_TOKEN` env > `auth.json`.
Write only on `login` success. File mode `0600`. Parent dir `0700`.
On read, if file mode is too permissive, log a warning and continue.
### CLI — `api-client.ts`
Thin wrapper over `fetch`:
```ts
export class ClaudemeshApi {
constructor(private opts: { baseUrl: string; token: string }) {}
async createMesh(input: { name: string; slug?: string }) { … }
async listMeshes() { … }
async createInvite(input: { meshId: string; email?: string; role?: string }) { … }
async listSessions() { … }
async revokeSession(id: string) { … }
async whoami() { … }
}
```
Type definitions live in `packages/api/src/contracts/cli.ts` (new file) — generated from the existing tRPC routers as plain types so the CLI doesn't need to import the whole tRPC client.
Base URL from `CLAUDEMESH_API_URL` env var, defaults to `https://claudemesh.com`. Allows local dev against `http://localhost:3000`.
### CLI — device-code login flow
```ts
// commands/login.ts
async function deviceCodeLogin() {
const device = collectDeviceInfo();
const { device_code, user_code, expires_at, verification_url } =
await api.requestDeviceCode(device);
console.log(` Opening ${verification_url}…`);
console.log(` Code: ${user_code}`);
await openBrowser(`${verification_url}?code=${user_code}`);
const spinner = ora("Waiting for confirmation").start();
const deadline = new Date(expires_at).getTime();
while (Date.now() < deadline) {
await sleep(1500);
const result = await api.pollDeviceCode(device_code);
if (result.status === "approved") {
spinner.succeed("Authenticated");
await authStore.write({ token: result.token, user: result.user, source: "device-code" });
await syncMeshes();
return;
}
if (result.status === "denied") {
spinner.fail("Denied in browser");
process.exit(1);
}
}
spinner.fail("Timed out");
process.exit(1);
}
```
Polls every 1.5s. Server returns `{ slow_down: true }` if polled too fast (rate limit at 1/sec).
## Security
1. **Tokens are hashed at rest** (Better Auth `apiKey` plugin handles this with bcrypt or argon2).
2. **Raw tokens shown to user once.** PATs in dashboard, device-code tokens via `claudemesh login` output. Never logged, never re-displayable.
3. **`auth.json` is `0600`.** CLI refuses to write if parent dir can't be made `0700`. Warns on read if mode is wider.
4. **Token prefix `cm_` enables secret scanning.** Document the regex `cm_(session|pat)_[a-z0-9]{32,}` in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks.
5. **`/api/auth/cli/device-code/:device_code` polling is rate-limited** to 1 req/sec per IP per device_code. Returns `429` with `slow_down: true` body.
6. **Device codes expire in 10 minutes.** Approved-but-unclaimed tokens stay valid (the polling endpoint still returns the token for 60 seconds after approval, then the device_code row is GC'd).
7. **Audit logging.** Every device-code approval, PAT creation, and PAT revocation emits an audit event (`auth.cli.session.created`, `auth.cli.pat.created`, etc.). Stored in existing audit log if there is one, otherwise new `audit_log` table.
8. **Session invalidation on password change.** When a user changes their password via Better Auth, all `cli_session` `api_key` rows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed).
9. **Token revocation is immediate.** `auth.api.verifyApiKey` checks DB on every request — no in-memory cache.
10. **No CSRF concern** for device-code endpoints — the unauthed ones don't act on user state, the authed ones use Better Auth's existing CSRF protection.
## Wizard UX integration
The current welcome wizard already has:
```
▸ Create account (new to claudemesh)
Sign in (existing account)
Paste an invite URL
Exit
```
After this spec lands, the welcome screen becomes:
```
██ claudemesh
▸ Sign in ← device-code OAuth
Paste an access token ← PAT path
Join via invite URL ← unchanged
Create account ← opens /register, then back to login
Exit
```
"Sign in" becomes the headline option. The current "Create account" still opens browser to `/register` but flows back through the device-code handshake instead of a custom callback.
Once authenticated, the wizard transitions to:
```
██ claudemesh launch
Account ✔ Alejandro Gutiérrez
Mesh ▸ (pick one — 3 available)
Name ✔ Alexis (from --name)
Role ▸ (pick one)
▸ Continue
Cancel
```
Status rows show what's filled and what's left. Mesh picker fetches from `GET /api/my/meshes` via the freshly minted token.
This integrates cleanly with the wizard architecture refactor in `2026-04-10-cli-wizard-architecture-refactor.md`: auth becomes one screen in the launch flow with `isComplete: s => s.user !== null`. On a fresh machine the auth screen runs; on a returning machine it's auto-skipped.
## Error handling
| Scenario | Behavior |
|---|---|
| Browser doesn't open | Print URL prominently, keep polling |
| Network down during poll | Retry with exponential backoff (1.5s → 3s → 6s, max 30s) |
| Device code expires | Print "Login timed out, run `claudemesh login` to retry", exit 1 |
| Token rejected by API | Print "Authentication failed", suggest `claudemesh login`, exit 2 |
| `auth.json` corrupted | Print "Auth file corrupted, run `claudemesh login`", exit 2 |
| `auth.json` permissions wrong | Warn, fix to `0600`, continue |
| PAT pasted to `--token` is malformed | Print "Invalid token format (expected `cm_pat_…`)", exit 1 |
| PAT pasted to `--token` is valid format but unknown | API returns 401, print "Token rejected", exit 2 |
| Two CLI instances poll simultaneously | Both get the same approved status; first to read gets the token, second gets `{ status: 'approved', token: null }` (already_claimed). Document this. |
| User clicks Approve in browser, then closes tab | CLI's poll catches it, login succeeds. The browser tab closure is irrelevant. |
| User completes login on machine A, then runs `claudemesh login` on machine B with same account | Both sessions coexist as separate `api_key` rows. `claudemesh whoami --sessions` shows both. |
## Implementation phases
Each phase ships independently and is independently testable.
### Phase 1 — Backend foundation (46 hours)
- [ ] Wire Better Auth `apiKey` plugin in `apps/web/src/lib/auth/`
- [ ] Migration `0020_cli-device-code.sql`
- [ ] Drizzle schema for `cli_device_code` in `packages/db/src/schema/auth.ts`
- [ ] Endpoints: `POST /api/auth/cli/device-code`, `GET /api/auth/cli/device-code/:device_code`, `POST /api/auth/cli/device-code/:user_code/approve`, `POST /api/auth/cli/device-code/:user_code/deny`
- [ ] Extend `enforceAuth` middleware to accept `Authorization: Bearer cm_…`
- [ ] Endpoints: `POST /api/my/cli/tokens`, `GET /api/my/cli/tokens`, `DELETE /api/my/cli/tokens/:id`, `GET /api/my/cli/sessions`, `DELETE /api/my/cli/sessions/:id`
- [ ] Unit tests for token verification and device-code state machine
### Phase 2 — Web routes (34 hours)
- [ ] `/cli-auth?code=...` page (server component + approve/deny client component)
- [ ] `/dashboard/settings/cli-tokens` page (list + create modal + revoke)
- [ ] Translations for both pages (en, es)
- [ ] E2E test: full device-code happy path with Playwright
### Phase 3 — CLI auth core (45 hours)
- [ ] `lib/device-info.ts` — collect hostname, os, arch
- [ ] `lib/auth-store.ts` — read/write `~/.claudemesh/auth.json` with mode checks
- [ ] `lib/api-client.ts` — typed fetch wrapper with bearer header
- [ ] `commands/login.ts` — device-code flow + `--token` PAT path
- [ ] `commands/logout.ts` — revoke + delete local
- [ ] `commands/whoami.ts` — print current identity + token source
- [ ] Token resolution helper (`--token` > `CLAUDEMESH_TOKEN` > `auth.json`)
- [ ] Unit tests for auth-store and token resolution
### Phase 4 — CLI commands wired to API (34 hours)
- [ ] Rewrite `commands/create.ts` to call `POST /api/my/meshes`
- [ ] New `commands/invite.ts` with `--email`, `--mesh`, `--role`, `--expires-in`
- [ ] Rewrite `commands/sync.ts` to call `GET /api/my/meshes` and reconcile local config
- [ ] Update `commands/list.ts` to show server-side meshes too
- [ ] Integration tests against staging broker + web
### Phase 5 — Wizard integration (34 hours)
- [ ] Welcome screen new options (Sign in / Paste token / Create account / Join invite)
- [ ] Auth screen as a flow step with `isComplete: s => s.user !== null`
- [ ] Status rows pattern showing auth state during launch
- [ ] First-run detection (no `auth.json`) → auto-route to login
### Phase 6 — Polish, docs, ship (23 hours)
- [ ] Update `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
- [ ] CHANGELOG entry for v0.11.0
- [ ] Telemetry events for `auth.cli.login.{start,success,fail}`
- [ ] Bump `apps/cli/package.json` to `0.11.0`
- [ ] Publish to npm
- [ ] Deploy broker / web (no broker changes, web for new routes)
**Total estimate:** 1926 hours of focused work. Realistic: 34 days with testing and review.
## Dependencies between phases
```
Phase 1 (backend) ──┬─→ Phase 2 (web routes)
└─→ Phase 3 (CLI auth core)
└─→ Phase 4 (commands)
└─→ Phase 5 (wizard)
└─→ Phase 6 (ship)
```
Phase 1 and 2 can be parallelized after the schema lands. Phase 3 needs Phase 1 endpoints live (even if on staging). Phase 4 onwards is strictly serial.
## Telemetry
Emit these events (PostHog or whatever the existing analytics are):
- `cli.login.started` — properties: `{ method: 'device-code' | 'pat' }`
- `cli.login.succeeded` — properties: `{ method, user_id }`
- `cli.login.failed` — properties: `{ method, reason }`
- `cli.logout` — properties: `{ user_id }`
- `cli.command.executed` — properties: `{ command, exit_code, duration_ms, authenticated: boolean }`
- `cli.api.error` — properties: `{ endpoint, status, error_code }`
Telemetry is **opt-out**. First run shows a one-line notice: "claudemesh collects anonymized usage telemetry. Disable with `claudemesh telemetry off`."
## Open questions
1. **Better Auth `apiKey` plugin version** — confirm it's installed and at a version that supports `enableMetadata`. Check `pnpm why better-auth` in `apps/web`.
2. **Audit log table** — does one already exist? If not, this spec adds three rows of log; not worth a new table for that. Use `console.log` with structured JSON to stderr and let the platform's log collector handle it.
3. **Email sending** — `claudemesh invite --email` requires a transactional email path. Does the web app already have one (Resend, Postmark)? If yes, reuse. If no, defer the email send to a follow-up; the invite command can still create the invite and print the URL.
4. **Token scopes** — v1 ships with no scopes; every token has full account access. Should we add `mesh:read`, `mesh:write`, `invite:create` scopes from day one, or wait? **Recommendation:** wait. YAGNI. Add when a user actually wants a read-only CI token.
5. **PAT expiry default** — 90 days? 1 year? Never? Better Auth supports all three. **Recommendation:** 1 year default, user can pick "never" with explicit warning.
6. **Mesh slug uniqueness in `claudemesh create`** — what happens if two users try to create meshes with the same slug? Existing API behavior should be tested. If it errors, the CLI should suggest `--slug platform-team-2`.
7. **`claudemesh login` when already logged in** — re-authenticate (overwrite) or error ("already logged in, run logout first")? **Recommendation:** re-authenticate silently with a one-line notice ("Replacing existing session for Alejandro").
## Acceptance criteria
For v0.11.0 to ship, all of these must be true:
- [ ] `claudemesh login` on a fresh machine (no `auth.json`) opens browser, completes device-code flow, writes `auth.json`, runs in <30 seconds end-to-end
- [ ] `claudemesh login --token cm_pat_…` works without browser
- [ ] `claudemesh logout` revokes server-side and deletes local file
- [ ] `claudemesh whoami` prints user identity and token source
- [ ] `claudemesh create "Test mesh"` creates a real mesh on the server, joins it locally, and the user can see it on the dashboard
- [ ] `claudemesh invite --email a@b.c --mesh test` creates an invite and prints the URL
- [ ] `claudemesh launch` (bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard
- [ ] Dashboard `/dashboard/settings/cli-tokens` lists, creates, and revokes PATs
- [ ] All flows work in `en` and `es`
- [ ] Existing `claudemesh launch` invocations (with token already in `auth.json`) still work without prompting
- [ ] Token in `auth.json` survives an hour of idle and continues to work (no aggressive expiry)
- [ ] Revoking a token in the dashboard makes the next CLI call fail with a clear error
- [ ] Documentation updated in `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
- [ ] CHANGELOG entry written
- [ ] Published to npm as `claudemesh-cli@0.11.0`
## What this unlocks
Once this lands, every dashboard-only feature becomes one CLI command away. Future specs that depend on this:
- `claudemesh members list` / `claudemesh members add`
- `claudemesh billing usage`
- `claudemesh mesh archive`
- `claudemesh stream subscribe` (live broker events)
- `claudemesh skill publish` (publish a skill to mesh registry)
- `claudemesh log tail` (mesh-wide audit log)
This is the foundational unlock. Everything else is incremental on top.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
# Broker HA readiness — statelessness audit
Single-instance broker is the biggest GA blocker. Moving to 2+ replicas
behind a load balancer requires first understanding which state the broker
holds in-process that breaks if split across nodes.
## Current in-process state (apps/broker/src/index.ts)
| Symbol | Line | Per-node? | Survives HA? | Notes |
|--------|------|-----------|--------------|-------|
| `connections` | 147 | yes (WS state) | ✅ naturally per-node | WS connections are pinned to a node by L7 routing. Each node holds only its own connections. **OK as long as the LB uses sticky sessions or cross-node fan-out.** |
| `connectionsPerMesh` | 148 | yes | 🟡 per-node count, not global | Used for capacity cap. Global cap requires Redis. |
| `tgTokenRateLimit` | 151 | yes | 🟡 per-node | Telegram bot rate limiting; tolerable as per-node. |
| `urlWatches` | 173 | yes | 🔴 stuck on one node | If peer disconnects from node A and reconnects on B, the watch stays orphaned on A. **Needs DB/Redis, or "pin to owning node". Acceptable risk if watches are per-session ephemeral.** |
| `streamSubscriptions` | 259 | yes | 🔴 multi-node broken | Sub on A, publish on B → message never reaches A's subscribers. **Needs Redis pub/sub for HA.** |
| `meshClocks` | 270 | yes | 🔴 multi-node broken | Simulated clocks must be single-authority. Solve by pinning one node as clock leader (simple leader election) or by moving clock state to DB. |
| `mcpRegistry` | 327 | yes | 🔴 multi-node broken | MCP server catalog cached in memory. If deployed on A but called on B, B doesn't know it exists. **Must be DB-backed** (partly is already — see `mesh_service` table). Audit the cache/DB sync path. |
| `mcpCallResolvers` | 338 | yes | ✅ per-call ephemeral | In-flight callback resolvers; WS sticks to owning node so this is fine. |
| `scheduledMessages` | 359 | yes | 🔴 multi-node broken | Scheduled delivery timers live in-process. Restart loses them. Persistence exists (`scheduled_message` table) + recovery on startup, but two nodes could both fire the same timer. **Needs a leader lock or per-schedule pg_advisory_lock on fire.** |
| `sendRateLimit` | index.ts:494 | yes | 🟡 per-node | Each node enforces its own quota; a client spread across nodes could 2x the limit. Tolerable if sticky sessions hold. |
| `hookRateLimit` | index.ts:482 | yes | 🟡 per-node | Same as sendRateLimit. |
| `lastHash` (audit.ts:22) | — | yes | 🔴 broken on write | Two nodes writing audit rows concurrently will BOTH read the same last hash, BOTH compute a new hash, and both INSERT — the chain forks. **Needs `SELECT FOR UPDATE` or a single audit writer.** |
## Conclusion
**Current broker is NOT HA-safe.** Five symbols break under multi-instance:
`urlWatches`, `streamSubscriptions`, `meshClocks`, `mcpRegistry` cache,
`scheduledMessages`, `lastHash`. None are unsolvable, but none are
trivial.
## Rollout plan for HA
### Phase 0 (now) — sticky sessions
Deploy a single broker behind Traefik with `loadBalancer.sticky.cookie`
enabled. WS upgrade inherits the cookie, so reconnects land on the same
node. Gives us 1 node of safe HA headroom (i.e., one deploy rollover
without user-visible disconnection) without any code changes.
### Phase 1 — Active/passive
Two replicas. Traefik routes all traffic to primary; secondary is warm.
Primary fails → secondary takes over, all WS connections reset. No code
change needed; clients auto-reconnect.
### Phase 2 — Active/active for stateless routes
HTTP-only routes (`/cli/*`, `/download`, `/hook`) can round-robin across
any number of replicas today. WS routes stay sticky per mesh via Traefik
`sticky.cookie`. Already behind Postgres → each replica reads the same
mesh/member/invite rows.
### Phase 3 — Full active/active
Migrate the 6 problematic in-memory symbols:
- `streamSubscriptions` → Redis pub/sub
- `meshClocks` → leader-elect via Postgres advisory lock on mesh_id
- `scheduledMessages` → single-writer pattern: whichever replica holds
`pg_advisory_xact_lock(schedule_id)` fires
- `urlWatches` → DB-backed + each replica owns watches where
`presence.node_id = this_node`
- `mcpRegistry` → rely on `mesh_service` table, drop the in-memory cache
- `lastHash` → wrap audit.ts writes in a transaction that
`SELECT hash FROM audit_log ... ORDER BY id DESC FOR UPDATE`, making
concurrent inserts serialize.
### Phase 4 — Multi-region
SPOF at Frankfurt (OVH). Move to a managed Postgres with read replicas,
one broker cluster per region, global DNS geo-routing. Out of scope for
v1.0.0.
## Immediate ship: local docker-compose for 2-replica smoke test
`packaging/docker-compose.ha-local.yml` (TODO) spins up:
- 2x broker (same DATABASE_URL)
- 1x postgres
- 1x traefik with sticky cookie
- 1x locust / synthetic client
Tests:
1. Send to peer connected on node A → delivered.
2. Subscribe on A, publish on B → expect failure (documents the gap).
3. Kill node A → client reconnects to B within Xs.
4. Audit chain verify after concurrent writes from both nodes → expect
a fork (documents the gap).
## Decision
**Ship v1.0.0 on sticky-session single-writer (Phase 0 + Phase 1 warm
standby).** That closes the "what happens on deploy" story. Phase 3 full
HA is v1.1.0 work.

View File

@@ -0,0 +1,71 @@
# Feature request draft: rich `<channel>` notification UI
**Target:** `anthropics/claude-code` GitHub issues / feedback channel.
**Drafted:** 2026-04-15.
Paste the section below once the issue template is ready. Adjust tone
to match Claude Code's issue style.
---
### Title
Rich UI for `notifications/claude/channel` messages (first-class chat, not just reminders)
### Body
**Summary**
MCP servers can emit `notifications/claude/channel` notifications which
Claude Code renders inside the current turn as a `<channel>` reminder.
For MCP servers that are conversational in nature (peer messaging,
collaborative sessions, delegated agents), rendering these inline as
plain-text reminders misses the UX affordances users expect from chat:
- sender avatar / identity
- timestamp
- priority badge (urgent / normal / low)
- expandable quote from the original thread
- optional inline reply action that calls a specific MCP tool
**Concrete use case**
[claudemesh](https://claudemesh.com) is a peer mesh for Claude Code
sessions. When a peer sends a message it arrives as
`notifications/claude/channel` with structured metadata in `meta`:
```json
{
"method": "notifications/claude/channel",
"params": {
"content": "alice: can you rebase main before deploy?",
"meta": {
"from_id": "<ed25519 hex>",
"from_name": "alice",
"priority": "now",
"sent_at": "2026-04-15T00:00:00Z",
"mesh_slug": "team-platform",
"kind": "direct"
}
}
}
```
Today this renders as a `<channel>` text block — useful, but the user
can't tell at a glance that it's from another human.
**What we'd like**
A hint on the notification (e.g. `meta.display: "chat"`) that lets
Claude Code render it as a chat bubble with the `from_name` as the
speaker, priority visualised, and an optional "Reply" action bound to
a declared MCP tool (`reply_tool_name`).
**Why users would benefit beyond claudemesh**
- Delegated agent frameworks can render sub-agent responses as chat
- Live-pairing MCP servers get a proper UI without inventing their own
- The existing `<channel>` fallback means older clients still see
the same text — additive, not breaking
**Willing to contribute a PR** if the feature is on-roadmap.

View File

@@ -0,0 +1,58 @@
# CLI Distribution Pipeline
## Status
- Shell installer (`/install`): ✅ live, needs polish
- Single-binary build script (`scripts/build-binaries.ts`): ✅ written, not wired to CI
- GitHub Releases publish: ❌ not set up
- Homebrew tap: ❌ not set up
- winget manifest: ❌ not set up
## Shipped this session (alpha.28)
- `bun build --compile` script at `apps/cli-v2/scripts/build-binaries.ts` produces
`dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}` locally.
- `/install` updated to use the one-command `claudemesh <invite-url>` flow.
- `claudemesh url-handler install` registers the `claudemesh://` scheme on the three OSes.
## What's missing
### 1. GitHub Actions to build + publish binaries
```yaml
# .github/workflows/release-binaries.yml
on: { push: { tags: ['v*'] } }
jobs:
build:
strategy: { matrix: { target: [darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64] } }
steps:
- uses: oven-sh/setup-bun@v2
- run: cd apps/cli-v2 && bun install --frozen-lockfile
- run: cd apps/cli-v2 && bun run scripts/build-binaries.ts
- uses: softprops/action-gh-release@v2
with: { files: apps/cli-v2/dist/bin/* }
```
### 2. `/install` detects missing Node and downloads a binary
Current `/install` requires Node 20+. Next iteration: detect absence, curl the
right binary from GitHub Releases, drop it in `~/.claudemesh/bin/`, add to PATH.
### 3. Homebrew tap (`homebrew-claudemesh`)
Separate repo with a formula that points at the GitHub Release artifact.
Users: `brew install alezmad/claudemesh/claudemesh`. Auto-updated by the
release workflow via `brew bump-formula-pr`.
### 4. winget manifest
YAML in `microsoft/winget-pkgs` repo pointing at the Windows .exe.
### 5. Auto-update in-CLI
Already have `showUpdateNotice`. Upgrade to offer `claudemesh upgrade` that
re-runs `/install` OR downloads a new binary in place.
## Why this matters
Current state: users need Node, npm, and patience. Goal state:
```
curl -fsSL claudemesh.com/install | sh
```
…and that's it, on any OS, with or without Node.
## Priority
After tier-1 usability (done), this is the next biggest lever for adoption.
Estimate: 1-2 days for full pipeline, mostly CI config + release testing.

View File

@@ -0,0 +1,152 @@
# claudemesh crypto — external review packet
**Goal:** 2-day review of the claudemesh cryptographic surface by an
external reviewer familiar with libsodium, x25519/ed25519, authenticated
encryption, and hash-chain audit logs.
**Status:** self-audited + Codex-reviewed. Not yet reviewed by an
independent human with security expertise.
## Scope
### Files in scope
| File | LoC | What it does |
|---|---|---|
| `apps/broker/src/crypto.ts` | ~400 | Hello signature verification, canonical invite bytes (v1+v2), `sealRootKeyToRecipient` via `crypto_box_seal`, `verifyInviteV2`, `claimInviteV2Core` (gated). |
| `apps/broker/src/broker-crypto.ts` | 70 | AES-256-GCM encryption-at-rest for MCP env vars. Key from `BROKER_ENCRYPTION_KEY` or ephemeral in dev. |
| `apps/broker/src/audit.ts` | ~250 | Hash-chained audit log. Canonical JSON payload hash, per-mesh `pg_advisory_xact_lock` for concurrent writers. |
| `apps/cli/src/services/crypto/box.ts` | 60 | `crypto_box_easy` / `crypto_box_open_easy` wrappers that accept ed25519 keys and convert to curve25519 via `crypto_sign_*_to_curve25519`. |
| `apps/cli/src/services/crypto/keypair.ts` | ~50 | `generateKeypair` wrapping `crypto_sign_keypair`. |
| `apps/cli/src/commands/backup.ts` | ~180 | Config backup via Argon2id + XChaCha20-Poly1305 (`crypto_aead_xchacha20poly1305_ietf_*`) from a user passphrase. |
| `apps/cli/src/services/invite/parse-v1.ts` | ~160 | Invite payload decode + signature verification, URL parsing, short-code resolution. |
### Out of scope
- TLS config (Traefik termination)
- Postgres at-rest disk encryption
- Homebrew/winget binary signing pipeline
- Secrets storage on the user's machine (we rely on OS file mode 0600)
## Threat model
### Adversary profile
- **Network attacker** on the wire between CLI and broker. Controls
DNS, can inject packets, can replay. TLS terminates at Traefik;
assume TLS is trusted.
- **Malicious broker** operator. Can read any row in Postgres.
- **Mesh peer** with a valid member record. Can try to escalate
privileges, impersonate other members, replay, DoS, exfiltrate
other members' messages.
- **Laptop thief** who has the user's `~/.claudemesh/` directory but
not the login password. (Keys on disk at mode 0600.)
### Must hold
- E2E: broker cannot read plaintext of direct messages.
- Signature: no member can forge messages signed as another member.
- Invite integrity: modifying an invite URL invalidates the signature.
- Backup secrecy: an attacker with the backup file but not the
passphrase learns nothing.
- Audit integrity: tampering with an audit row breaks chain
verification.
### Known weaknesses (deliberate)
- **root_key in v1 invite URL**: current long URL form carries the
mesh root key in base64(JSON). Short-URL mode (`/i/<code>`) resolves
to the same token server-side, so this does NOT reduce the exposure.
v2 protocol moves root_key out of the URL but CLI migration is not
yet shipped.
- **Session-key routing identity**: a peer can claim arbitrary
`sessionPubkey` in hello (validated as 64-hex in alpha.36 but not
proven-own). Proof-of-secret-key for session key is not enforced.
Impact: a peer can route messages as any session pubkey it chooses
but cannot decrypt replies without the matching secret, so the
impact is DoS/confusion, not impersonation.
- **mesh.owner_secret_key stored plaintext** in the DB. A malicious
broker can issue arbitrary invites. Mitigated only by DB access
control.
## Review checklist for the reviewer
1. **libsodium usage**
- Are nonces generated with `randombytes_buf` and never reused?
- `crypto_box_easy` / `crypto_box_open_easy` order and parameters correct?
- Are ed25519 keys converted to curve25519 on BOTH sides consistently?
- Is `crypto_sign_detached` / `crypto_sign_verify_detached` used with the right message bytes?
2. **Invite protocol**
- Canonical bytes v1 + v2 format strings stable across CLI and broker?
- Replay protection: is a v1 URL reusable? (short URL + usedCount)
- Is the `maxUses` counter race-safe? (atomic UPDATE with `lt`)
- v2 root_key sealing: does `crypto_box_seal` fit the trust model?
- Is recipient_x25519_pubkey validated on both shape and length?
3. **Audit chain**
- Is the canonical JSON serialization reviewable and stable?
- Does `pg_advisory_xact_lock` actually serialize writes on the same mesh under HA?
- Can a malicious broker rewrite history by dropping the `lastHash` cache + DROPping rows + replaying with a new chain? (Yes — documented. Mitigation is append-only at the DB level.)
4. **At-rest encryption (broker-crypto.ts)**
- AES-256-GCM with 12-byte IV + 16-byte tag — correct, but is the IV generation guaranteed random and unique per encryption?
- Any concern about auth tag truncation or nonce collision under high volume?
5. **Backup (cli/commands/backup.ts)**
- Argon2id params reasonable? (INTERACTIVE — should possibly be SENSITIVE.)
- XChaCha20-Poly1305 parameter order?
- Does the passphrase-minimum (12 chars) match the Argon2id parameters?
- Is the salt stored alongside the ciphertext and read back correctly?
6. **Session vs member key**
- When is which key used? Is there any path where one is trusted for the other's purpose?
7. **Hello signature**
- Timestamp skew window (`±60s`) — does the broker reject out-of-window replays?
- Is the canonical hello string covered by the signature exactly?
8. **Grants**
- Can a peer bypass server-side grant enforcement by lying about their
own sender key in hello? (Signature pins memberPubkey to a real
signing key, but sessionPubkey isn't proven.)
## Test coverage supplied
- `apps/broker/tests/invite-signature.test.ts`
- `apps/broker/tests/invite-v2.test.ts`
- `apps/broker/tests/hello-signature.test.ts`
- `apps/broker/tests/audit-canonical.test.ts`
- `apps/broker/tests/grants-enforcement.test.ts`
- `apps/broker/tests/rate-limit.test.ts`
- `apps/broker/tests/encoding.test.ts`
- `apps/broker/tests/dup-delivery.test.ts`
- `apps/cli/tests/unit/crypto-roundtrip.test.ts`
## Deliverables expected from reviewer
1. **Findings list** — severity (crit/high/med/low), file:line, fix recommendation.
2. **Protocol-level critique** — anything in the invite or hello flow that can be exploited with a valid account.
3. **Tooling recs** — libsodium best-practice they'd follow differently.
4. **Go/no-go** for v1.0.0 GA assuming the findings are addressed.
## Budget
2 person-days. Hourly rate acceptable; fixed-fee preferred. Request
for quote from reviewers with published libsodium / PKI experience
(see recommended list below).
## Recommended reviewers
- Filippo Valsorda (independent, ex-Go crypto lead, known for age/tink reviews)
- Trail of Bits (firm-rate; their Tamarin+reviewer combo is strong)
- Latacora (firm; expensive but thorough)
- NCC Group (firm; good for libsodium-specific)
- Cure53 (firm; EU, fast turnaround)
## Review deliverable format
Markdown report with:
- Findings table (id, severity, file:line, summary, recommended fix)
- Protocol notes
- One-page exec summary for non-technical stakeholders

View File

@@ -0,0 +1,84 @@
# Invite v2 — CLI migration (server-side already shipped)
## Current state
**Server-side (broker) — DEPLOYED**
- `canonicalInviteV2` bytes format (crypto.ts)
- `verifyInviteV2` signature check
- `claimInviteV2Core` at `POST /invites/:code/claim`
- `sealRootKeyToRecipient` using crypto_box_seal
- Every v1 invite also stores `capability_v2` for cross-compat
- Web route `/api/public/invites/:code/claim` proxies to broker
**Client-side (CLI) — NOT MIGRATED**
The CLI still uses the v1 flow (`enrollWithBroker`) which reads
`mesh_root_key` from the invite token's base64 payload. This means:
- Long URL `/join/<token>` contains the root key
- Short URL `/i/<code>` resolves to the long URL → still contains root key
- Anyone who can read the URL (history, screenshot, mail archive) has the key
## The v2 CLI flow
```
parseInviteLinkV2(url)
→ short URL /i/<code>? GET /api/public/invite-code/:code
→ returns `{ found, code, mesh_slug, broker_url, owner_pubkey,
canonical_v2, expires_at, role }` (NO root_key)
→ generate local x25519 keypair (curve25519)
→ POST /invites/<code>/claim { recipient_x25519_pubkey, display_name }
→ broker verifies capability_v2 signature
→ broker seals mesh.root_key with crypto_box_seal(root_key, our_pubkey)
→ returns { sealed_root_key, mesh_id, member_id, owner_pubkey, canonical_v2 }
→ open sealed_root_key with our x25519 secret key
→ store root_key in ~/.claudemesh/config.json.meshes[].rootKey
(NOT in the invite link — it was never transmitted unsealed)
→ upgrade enroll to use claim response instead of the /join endpoint
```
## What needs to change in the CLI
1. **New file** `apps/cli/src/services/invite/parse-v2.ts`
- Detect short URL, resolve via `/api/public/invite-code/:code`
- Expect the API returns v2 shape (server already has this route; verify field names)
- Generate x25519 keypair via libsodium
- POST to claim endpoint
- Unseal root_key
2. **Conditional in `parseInviteLink`**
- If URL is short-form and broker supports v2, use the new path
- Fall back to v1 for legacy long-form URLs in transit
3. **Config schema** already has `rootKey` per mesh — just write from
unsealed bytes instead of from the token payload.
4. **Spec test** `tests/golden/invite-v2.test.ts`
- Broker already has `claimInviteV2Core` tests; add a CLI-side
end-to-end that hits a local broker and verifies the sealed key
round-trips.
## Why it wasn't rushed in this session
Crypto code deserves review. The server-side v2 shipped weeks ago
with its own testing and audit; the CLI migration needs the same
rigor — at minimum, a test that proves the sealed key we unseal
matches the root_key the broker had in its DB, verified against
`canonical_v2` signature.
The current v1 flow is a known quantity (the root_key-in-URL risk
is documented in the spec). Broker is already v2-ready so when the
CLI migration lands, emails / links can immediately start using the
claim-only short URL without a server deploy.
## Rollout plan
1. Ship CLI v2 path behind `CLAUDEMESH_INVITE_V2=1` env.
2. Dogfood: new invites generated by `claudemesh share` use `/api/public/invite-code/:code` with v2-shape response that omits token; CLI resolves via claim.
3. Verify with `claudemesh verify` safety numbers cross-check.
4. After 2 weeks uneventful, flip default to v2.
5. After 60 days, stop embedding root_key in long URLs entirely.
6. v3 (future): short URL becomes the only form.
## Effort
~1 day of focused crypto + testing. Broker work is done; API work is
done; CLI work is a new parse path + a new enroll path + a few tests.

View File

@@ -0,0 +1,75 @@
# Per-Peer Capabilities
## Goal
Give mesh members fine-grained control over what peers can do to their
session. Today: any mesh peer can send you any message; all messages get
pushed as `<channel>` reminders. Users can't say "only @alice can send me
messages," "read-only peers," or "@bob can broadcast but not DM."
## Current state
- Mesh-level role: `admin` | `member` (only affects invite issuance)
- No per-peer filter — every peer message is delivered
- No per-peer read/write split (all peers have the same capabilities)
## Target capability model
| Capability | Meaning |
|--------------|--------------------------------------------------------|
| `read` | Peer appears in your list_peers, can see your summary |
| `dm` | Peer can send you direct messages |
| `broadcast` | Peer's group broadcasts reach you |
| `state-read` | Peer can read shared state keys |
| `state-write`| Peer can set shared state keys |
| `file-read` | Peer can read files you've shared (already exists) |
## CLI surface
```
claudemesh grant @alice dm broadcast # allow direct + broadcast
claudemesh grant @bob state-read # read-only
claudemesh revoke @alice broadcast
claudemesh grants # list current grants per peer
claudemesh block @spammer # shorthand for revoke-all
```
## Broker schema
New column on `mesh_member`:
```sql
peer_grants jsonb DEFAULT '{}'::jsonb
-- shape: { "<peer_pubkey_hex>": ["dm", "broadcast", ...] }
```
Alternative (cleaner): separate `peer_grant` table keyed on
`(member_id, target_pubkey)`.
## Enforcement point
Broker's message router (`apps/broker/src/index.ts` — send flow).
Before writing the encrypted message to the recipient's queue, check
`recipient.peer_grants[sender_pubkey]` against message kind. Drop
silently if disallowed (sender sees delivered, recipient sees nothing —
matches Signal/iMessage block semantics).
## Defaults
- Unknown peers: `read + dm` (matches current behavior — additive-safe rollout)
- Existing members: grandfathered into `read + dm + broadcast + state-read`
via a migration
- `claudemesh profile --default-grants read dm` lets users change their own default
## UI
- `claudemesh peers` renders a `[grants: dm,broadcast]` tag per peer
- `claudemesh verify` gains a `--with-grants` flag that shows the grant set
alongside the safety number (helps the "did I accidentally block them?" check)
## Crypto implications
Grants are server-enforced metadata. Not capability tokens. A malicious
broker could forward messages regardless — this is about UX trust (spam /
noise control), not protocol security. The spec is clear about this.
## Migration plan
1. Ship broker schema change (jsonb column, nullable, default `{}`).
2. Ship `grant/revoke/grants/block` CLI commands against an unused column.
3. Enable enforcement in broker behind a per-mesh feature flag.
4. Flip on for all meshes.
## Priority
Nice-to-have. The killer feature here is `block` — every mesh gets a bad
actor eventually. Ship `block` first even if the full grant system is deferred.

View File

@@ -0,0 +1,162 @@
---
title: MCP tool surface trim + multi-mesh push
status: proposed
target: claudemesh-cli 1.1.0
author: Alejandro
date: 2026-05-01
---
# MCP tool surface trim + multi-mesh push
## Problem
Two issues with the current `claudemesh mcp` server:
1. **80+ tools registered.** Every Claude session that has claudemesh installed pays the deferred-tool-list cost (~80 entries surfacing in `ToolSearch`). Most of those tools are CLI-verb-wrappers that already have a perfect Bash equivalent — no structured I/O is gained by exposing them as MCP tools.
2. **Single-mesh push only.** A session launched with `claudemesh launch` opens its WS to one mesh. Peer messages from any other joined mesh arrive only if the user manually runs `claudemesh inbox`. The MCP push pipeline doesn't fan out across meshes.
The cleanest framing: **MCP earns its keep when a tool returns structured data Claude reads. CLI is better for fire-and-forget verbs.** Today's tool surface ignores that distinction.
## Non-goals
- **Don't redesign the architecture as "CLI-only with a daemon."** That trades warm-WS sends (~5ms in-process) for cold Bash spawns (~300-500ms) and forces a Unix-socket bridge to recover state coherence. See discussion 2026-05-01 — the platform vision (vectors, graph, files, mesh-services) genuinely benefits from typed tool I/O.
- **Don't break MCP backward compat in 1.x.** Existing scripts calling `mcp__claudemesh__send_message` keep working until 2.0; in 1.1 they're soft-deprecated with a stderr warning.
## Proposal
Three patches, ship together as 1.1.0:
### Patch 1: `--mesh <slug>` flag on `claudemesh mcp`
Today `claudemesh mcp` calls `readConfig()` and `startClients(config)` — connects to every mesh in `~/.claudemesh/config.json`. The `claudemesh launch` flow writes a per-session tmpdir config with one mesh, so practically the MCP server binds to one mesh per session.
Add an explicit flag for non-launch contexts (manual `~/.claude.json` editing):
```ts
// apps/cli/src/mcp/server.ts, near line 244
export async function startMcpServer(): Promise<void> {
const serviceIdx = process.argv.indexOf("--service");
if (serviceIdx !== -1 && process.argv[serviceIdx + 1]) {
return startServiceProxy(process.argv[serviceIdx + 1]!);
}
const meshIdx = process.argv.indexOf("--mesh");
const onlyMesh = meshIdx !== -1 ? process.argv[meshIdx + 1] : null;
const config = readConfig();
if (onlyMesh) {
const before = config.meshes.length;
config.meshes = config.meshes.filter((m) => m.slug === onlyMesh);
if (config.meshes.length === 0) {
throw new Error(
`--mesh "${onlyMesh}" not found in config (have: ${
config.meshes.map((m) => m.slug).join(", ") || "none"
})`,
);
}
}
// ...rest unchanged
}
```
Enables this `~/.claude.json` pattern for users who want push from N meshes simultaneously without launching N Claude sessions:
```json
{
"mcpServers": {
"claudemesh:flexicar": { "command": "claudemesh", "args": ["mcp", "--mesh", "flexicar"] },
"claudemesh:openclaw": { "command": "claudemesh", "args": ["mcp", "--mesh", "openclaw"] },
"claudemesh:prueba1": { "command": "claudemesh", "args": ["mcp", "--mesh", "prueba1"] }
}
}
```
Each instance opens one WS, holds it for the session, decrypts and forwards `claude/channel` notifications independently. Channel events already carry `[meshSlug]` in `formatPush()` (server.ts:240), so Claude knows which mesh a message came from.
**LoC:** ~10. **Risk:** very low — additive flag, default behavior unchanged.
### Patch 2: trim 25 messaging tools from MCP surface
Move these tools from "registered MCP tool" to "soft-deprecated CLI shim":
| Module | Tool | CLI replacement | Rationale |
|---|---|---|---|
| messaging.ts | `send_message` | `claudemesh send <to> <msg> [--mesh X] [--priority Y]` | Pure verb, no structured return. |
| messaging.ts | `list_peers` | `claudemesh peers --json` | One-shot, easy to parse. |
| messaging.ts | `check_messages` | `claudemesh inbox --json` | One-shot. |
| messaging.ts | `message_status` | `claudemesh msg-status <id>` (new) | One-shot lookup. |
| profile.ts | `set_profile` | `claudemesh profile --avatar X --bio Y ...` | Pure write. |
| profile.ts | `set_status` | `claudemesh status set <state>` (new) | Pure write. |
| profile.ts | `set_summary` | `claudemesh summary <text>` (new) | Pure write. |
| profile.ts | `set_visible` | `claudemesh visible <true\|false>` (new) | Pure write. |
| groups.ts | `join_group` | `claudemesh group join @<name> [--role X]` (new) | Pure write. |
| groups.ts | `leave_group` | `claudemesh group leave @<name>` (new) | Pure write. |
| state.ts | `get_state` | `claudemesh state get <key> --json` | Already exists. |
| state.ts | `set_state` | `claudemesh state set <key> <value>` | Already exists. |
| state.ts | `list_state` | `claudemesh state list --json` | Already exists. |
| memory.ts | `remember` | `claudemesh remember <text>` | Already exists. |
| memory.ts | `recall` | `claudemesh recall <query> --json` | Already exists. |
| memory.ts | `forget` | `claudemesh forget <id>` (new) | Pure write. |
| scheduling.ts | `schedule_reminder` | `claudemesh remind <msg> --in/--at/--cron` | Already exists. |
| scheduling.ts | `list_scheduled` | `claudemesh remind list --json` | Already exists. |
| scheduling.ts | `cancel_scheduled` | `claudemesh remind cancel <id>` | Already exists. |
| mesh-meta.ts | `mesh_info` | `claudemesh info --json` | One-shot read. |
| mesh-meta.ts | `mesh_stats` | `claudemesh stats --json` (new) | One-shot read. |
| mesh-meta.ts | `mesh_clock` | `claudemesh clock --json` (new) | One-shot read. |
| mesh-meta.ts | `ping_mesh` | `claudemesh ping` (new) | Pure verb. |
| tasks.ts | `claim_task` / `complete_task` | `claudemesh task claim/complete <id>` (new) | Pure write. |
**Keep as MCP tools (~50):**
- **vault.ts** — `vault_set / vault_list / vault_delete` (encrypted, structured payloads).
- **vectors.ts** — `vector_store / vector_search / vector_delete` (typed embeddings, ranked results Claude reasons over).
- **graph.ts** — `graph_query / graph_execute` (returns structured graph results).
- **files.ts** — `share_file / get_file / list_files / list_peer_files / read_peer_file / grant_file_access / file_status / delete_file` (binary payloads, ACL semantics).
- **skills.ts** — `share_skill / list_skills / get_skill / remove_skill / mesh_skill_deploy` (typed skill metadata).
- **streams.ts** — `create_stream / list_streams / publish / subscribe` (event stream cursor semantics).
- **contexts.ts** — `share_context / get_context / list_contexts` (context-passing payloads).
- **mcp-registry-*.ts** — `mesh_mcp_*` (the ~14 mesh-MCP-services tools — these are platform-defining, MCP-native).
- **clock-write.ts** — `mesh_set_clock / mesh_pause_clock / mesh_resume_clock` (logical-clock writes that Claude composes with reads).
- **sql.ts** — `mesh_query / mesh_schema / mesh_execute` (typed SQL results).
- **webhooks.ts** — `create_webhook / list_webhooks / delete_webhook` (typed webhook metadata).
- **url-watch.ts** — `mesh_watch / mesh_unwatch / mesh_watches` (returns watch state).
- **tasks.ts** — `create_task / list_tasks` (typed task records — only the writes go to CLI).
### Patch 3: tool-call → CLI shim with deprecation warning
For the trimmed tools, keep the registration but route through the CLI:
```ts
// apps/cli/src/mcp/tools/messaging.ts (sketch)
async function sendMessageDeprecated(args: SendMessageArgs): Promise<ToolResult> {
process.stderr.write(
`[claudemesh] mcp__claudemesh__send_message is soft-deprecated in 1.1. ` +
`Use \`claudemesh send\` via Bash instead — it's faster and cleaner.\n`,
);
return originalSendMessageHandler(args); // unchanged behavior
}
```
In 2.0 the registrations get deleted entirely.
## Migration plan
1. **1.1.0** — ship all three patches. Existing users see deprecation warnings; nothing breaks.
2. **1.1.x** — collect feedback. If anyone has scripts hard-wired to the deprecated tools, surface in CHANGELOG.
3. **1.2.0** (~6 weeks later) — flip deprecation warnings to "removal in 2.0" messaging.
4. **2.0.0** — delete the 25 tool registrations. ToolSearch surface drops to ~50 entries.
## Open questions
- **Do we need a Unix-socket bridge between CLI sends and the MCP push-pipe** so they share one WS connection per mesh per session? Probably yes for `claudemesh send` warm-path performance, but it's a separate spec — file under `socket-bridge` after this lands.
- **Should `claudemesh launch` keep writing one MCP server entry** (current behavior, default for new users) or switch to the per-mesh-N-entries pattern from Patch 1? Recommend keeping single-entry default — Patch 1 is for advanced users who manually edit `~/.claude.json`.
- **Do `mesh_mcp_*` tools really belong in the keep list?** They're MCP-on-mesh management — their bias is RPC-shaped, not stream-shaped. Provisional yes; revisit if 1.1 reduces their use.
## Effort
- Patch 1: ~10 LoC + 1 test. ~30 min.
- Patch 2: ~25 tool-handler refactors (registration removed, CLI verb confirmed/added). Some new verbs (`status set`, `summary`, `visible`, `group join/leave`, `forget`, `stats`, `clock`, `ping`, `task claim/complete`, `msg-status`) need wiring through to existing broker-client methods. ~150 LoC, half a day.
- Patch 3: deprecation shim per trimmed tool. ~50 LoC, 1 hour.
**Total:** ~1 dev-day for 1.1.0. ToolSearch surface drops by ~30%, multi-mesh push works, no architectural disruption, platform tools stay typed.

View File

@@ -0,0 +1,234 @@
---
title: claudemesh North Star — CLI-first with claude/channel push-pipe
status: canonical
target: 2.0.0
author: Alejandro
date: 2026-05-02
supersedes: none
references:
- 2026-05-01-mcp-tool-surface-trim.md (first cut at the trim)
- SPEC.md
- docs/protocol.md
---
# claudemesh North Star
## The commitment, in one sentence
> **CLI is the canonical surface for every claudemesh operation. MCP exists for one thing: to deliver `claude/channel` push notifications mid-turn. That's the killer feature, and it's the only reason an MCP server runs at all.**
Everything else — sending messages, listing peers, sharing files, deploying mesh-MCPs, running graph queries, scheduling jobs, publishing skills — is invoked from the CLI, by humans, scripts, cron, hooks, or by Claude itself via Bash.
## Why this shape
1. **Mid-turn interrupt is the differentiator.** When peer A sends to peer B, B's Claude session pauses what it's doing and reads the message immediately. That requires `claude/channel` notifications routed through an MCP transport — Claude Code only watches MCP server connections for those events. **Lose that, and claudemesh becomes another inbox-polling pattern.** Every other primitive can degrade to "delivered at next tool boundary"; this one cannot.
2. **CLI is universal.** Bash works in scripts, hooks, cron, CI, terminals, automation, and Claude itself (via Bash tool calls). A primitive that exists as both an MCP tool and a CLI verb is double-maintenance with one calling convention nobody actually wants.
3. **JSON-on-stdout is enough structure.** Claude reads `claudemesh peers --json` exactly as well as it reads a typed MCP tool return. The CLI man page is the schema. The "MCP gives structured I/O" advantage was real when we were paying for nothing else, but warm-WS via socket bridge (below) closes the cost gap.
4. **Surface shrinks where it matters.** ToolSearch deferred-tool list drops from ~80 entries to ~0 entries (push-pipe registers no tools). Massive context-budget win for every Claude session.
## Prior art (this is not novel architecture)
The "live-state daemon + thin scriptable CLI talking via Unix socket" pattern is the canonical shape for CLIs in this category. Reviewers should not treat this as bespoke design:
- **Docker** — `dockerd` daemon, CLI talks via `/var/run/docker.sock`. `DOCKER_HOST` env override. `docker context` for multi-daemon switching.
- **Tailscale** — `tailscaled` daemon, `tailscale` CLI via socket. Per-key ACL identity model. Same peer-mesh-with-keypairs shape as claudemesh.
- **Stripe `listen`** — long-running CLI daemon receives webhook push, forwards to local consumer. Same push-pipe-as-CLI-subcommand shape.
- **Obsidian CLI** — talks to a running Obsidian instance via REST. **Notable: ships a Claude skill (`~/.claude/skills/obsidian-cli/SKILL.md`) that documents every verb and flag for Claude consumption — replacing MCP tool introspection entirely.**
Claudemesh's CLI-first + push-pipe + socket-bridge architecture is exactly this pattern. We are following the well-trodden path, not inventing a new one.
## The six architectural commitments
### 1. **MCP server is a push-pipe, full stop.**
The MCP entrypoint (`claudemesh mcp [--mesh <slug>]`) does exactly three things:
- Holds a WS connection to the broker for the meshes it's bound to.
- Decrypts inbound peer messages.
- Emits them as `claude/channel` notifications to the parent Claude Code session.
It registers **zero tools**. It advertises only `experimental: { "claude/channel": {} }`. Its `tools/list` returns an empty array. There is no surface to discover, search, or call.
One push-pipe per joined mesh, registered in `~/.claude.json` via `claudemesh install` (or auto-injected by `claudemesh launch`). The `--mesh` flag (shipped 1.0.3) makes this trivial.
### 2. **CLI is the canonical surface for every primitive.**
Every resource has uniform CLI verbs:
| Resource | Verbs |
|---|---|
| peer | `claudemesh peers [--json] [--mesh X]` |
| group | `claudemesh group join/leave @<n> [--role X]` |
| message | `claudemesh send <to> <msg>`, `claudemesh inbox`, `claudemesh msg-status <id>` |
| state | `claudemesh state get/set/list [--json]` |
| memory | `claudemesh remember/recall/forget` |
| task | `claudemesh task create/claim/complete/list` |
| file | `claudemesh file put/get/list/grant/delete` |
| vector | `claudemesh vector store/search/delete` |
| graph | `claudemesh graph query/execute/watch` |
| stream | `claudemesh stream create/publish/subscribe/list` |
| context | `claudemesh context share/get/list` |
| skill | `claudemesh skill publish/list/get/remove` |
| schedule | `claudemesh schedule msg/webhook/tool/list/cancel` |
| webhook | `claudemesh webhook create/list/delete` |
| watch | `claudemesh watch create/list/unwatch` |
| mcp | `claudemesh mesh-mcp deploy/list/call/undeploy/catalog` |
| clock | `claudemesh clock get/set/pause/resume` |
| sql | `claudemesh sql query/schema/execute` |
| vault | `claudemesh vault set/get/list/delete` |
| profile | `claudemesh profile/summary/visible/status set` |
**Every verb supports `--json`** for structured consumption. **Every verb supports `--mesh <slug>`** for targeting (default: pick first or interactive picker). Verbs share one broker-call implementation — no duplication between CLI and MCP.
### 3. **Warm path via Unix socket bridge** (load-bearing for 2.0).
A push-pipe holds a live WS connection. CLI invocations should reuse that connection rather than opening their own (which costs ~300-500ms cold-start).
Mechanism:
- On startup, push-pipe creates `~/.claudemesh/sockets/<mesh-slug>.sock` (Unix domain socket, mode 0600).
- CLI verbs that need broker round-trip first try to dial that socket.
- If alive: forward request, get response back over socket (~5ms).
- If absent / stale: open ephemeral WS, do the op, close (~300ms — fine for cron/scripts where there's no parent push-pipe).
Push-pipe owns one WS, all ops through that WS, broker sees ONE session per mesh per host (no duplicate hellos). On crash, socket file is unlinked by `unlink` on exit handler; stale-socket detection by `connect()` ECONNREFUSED.
This is **mandatory for 2.0** — without it, every CLI op pays cold-start, and CLI-first becomes unusably slow for tight loops.
### 4. **JSON output is the schema, with field selection and streaming.**
Every CLI verb has a deterministic `--json` output shape, documented in `docs/cli-schemas.md`, validated by zod parsers in tests. Claude reads `claudemesh vector search "x" --json` and gets a typed-array shape it can reason over identically to a tool return.
**Three output modes, mandatory across every read-shaped verb** (modeled on `gh` and `gemini`):
- `--json` — full record, all fields
- `--json <fields>` — field-selected projection (e.g. `claudemesh peers --json name,pubkey,status`)
- `--output-format stream-json` — incremental JSONL for long-running ops (mesh-MCP calls fanning across peers, `vector search` against large indexes, `schedule list` with many entries). One object per line, Claude consumes incrementally.
Plus convenience output:
- `--jq <expr>` — native jq filter pipeline
- `--template '{{.field}}'` — Go template formatting
`schema_version: "1.0"` field on every JSON output — mandatory. Bumps when shape changes. Old code paths can pin with `--schema-version=1.0`.
### 5. **All features stay. Nothing is removed.**
This is **not a feature trim**. Every primitive in the current 80-tool surface gets a CLI verb. Vectors, graphs, mesh-MCP, files, vault, SQL — all of it. The user-facing pitch is unchanged: "claudemesh gives your Claude session a name, a network, shared memory, shared compute, shared skills, scheduled actions." The change is *how you call it*.
### 6. **The Claude skill IS the schema.** *(load-bearing for CLI-first to work)*
Stripping MCP tool introspection (`tools/list`) costs Claude its discoverability. The replacement: a packaged `claudemesh` skill at `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`, documenting every verb, flag, JSON shape, and gotcha. Claude reads it on demand via the Skill tool — **not on every session, not pre-loaded into deferred-tool-list**. This is exactly how `obsidian-cli` works today and it works perfectly.
The skill replaces three things at once:
- **Tool discovery** — Claude knows the verb-set after one Skill invocation. No `tools/list` needed.
- **Output schemas** — every JSON shape is documented in the skill, so Claude knows what to expect from `--json` without parsing TypeScript types at runtime.
- **Behavioral conventions** — the skill teaches "preview before delete," "confirm peer match before kick," "use `--mesh` for cross-mesh ops" — soft guardrails that complement the policy engine's hard rules.
Topic-shards for size: `claudemesh` (core), `claudemesh-platform` (vault/vectors/graph/sql/mesh-mcp), `claudemesh-schedule` (cron/webhooks/watches), `claudemesh-admin` (kick/ban/grants/install). Each shard is independently loadable.
**This is the answer to the "JSON-on-stdout is a worse schema" caveat.** It's not — when Claude has a documented skill to load, the CLI surface is *more* discoverable than 80 deferred MCP tools that bloat ToolSearch silently.
### 7. **Pluggable policy engine, not binary `--yes`.** *(answers the Bash-blast-radius caveat)*
Modeled on `gemini --policy / --admin-policy` and `codex --sandbox`. Replace the current binary `-y/--yes` with:
- **`--approval-mode plan|read-only|write|yolo`** — four levels (read-only blocks all writes, plan blocks all side effects, write prompts on dangerous verbs, yolo skips all confirmation).
- **`--policy <file>`** — YAML allow/deny rules per resource × verb × peer. Sample:
```yaml
# ~/.claudemesh/policy.yaml
default: prompt
rules:
- resource: send
verb: "*"
decision: allow
- resource: sql
verb: execute
decision: prompt
- resource: file
verb: delete
decision: deny
- resource: mesh-mcp
verb: call
peers: ["@trusted"]
decision: allow
```
Policy decisions log to a tamper-evident audit file. Org admin can ship `--admin-policy` that overrides user config. **This is the real answer to "Bash carries unrestricted blast-radius once allowed" — claudemesh's own policy engine kicks in before the broker call, regardless of what shell permissions are.**
## What this means for `claude/channel`
When peer A's CLI runs `claudemesh send peer-B "hello"`:
1. CLI dials `~/.claudemesh/sockets/<mesh>.sock` (warm path) or opens its own WS (cold).
2. Encrypts message with peer-B's pubkey via crypto_box.
3. Broker receives `send` envelope, forwards encrypted blob to peer-B's connected push-pipe.
4. Peer-B's push-pipe decrypts and emits a `claude/channel` notification.
5. Claude Code mid-turn-injects the message as a `<channel source="claudemesh" ...>` reminder.
6. Claude responds immediately per the system prompt convention.
Step 5 is the **only step that requires MCP**. Steps 1-4 are pure CLI + broker. The architecture is "CLI for everything, MCP for the one thing it's irreplaceable for."
## Migration path from 1.1.0
| Version | Ships | Behavior |
|---|---|---|
| **1.2.0** | Unix socket bridge. CLI verbs auto-detect push-pipe and use warm path. **Field-selectable JSON (`--json a,b,c`)** + `--jq` + `--template` adopted. | All existing MCP tools still work. Nothing breaks. |
| **1.2.1** | Ships `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`. Includes full verb reference + output schemas + gotchas. Topic-shards (`-platform`, `-schedule`, `-admin`). | Skill auto-installs on `claudemesh install`. |
| **1.3.0** | Schedule unification (`schedule msg/webhook/tool`). All remaining missing CLI verbs (file, vector, graph, mesh-mcp, vault, sql, stream, context, skill, watch). **`--output-format stream-json`** for long-running ops. | All existing MCP tools still work. New verbs additive. |
| **1.4.0** | Resource-model rename pass — every CLI verb is `<resource> <verb>`. Old verbs become aliases. | All existing MCP tools still work. Old CLI verbs aliased forever. |
| **1.5.0** | **Pluggable policy engine** (`--approval-mode`, `--policy`, `--admin-policy`). MCP `tools/list` shrinks to configurable allowlist (default: empty). `CLAUDEMESH_MCP_FAT=1` for users who need typed tool surface. | Default 1.5 install: MCP exposes zero tools. Push-pipe-only. Policy engine gates all writes. |
| **2.0.0** | MCP server hardcoded to push-pipe-only. Strip all tool registrations + handlers. | **Old MCP tool calls return tool-not-found.** Users must update scripts to CLI verbs. Old CLI verbs (1.4 aliases) still work. |
## What stays exactly the same
- Crypto: ed25519 sign + x25519 sealing + crypto_box for DMs. No change.
- Broker protocol: WS frame format, hello flow, audit log. No change.
- Membership / mesh-scope / capability grants. No change.
- Web app, dashboard, Telegram bridge, OAuth. No change.
- The platform vision (vault, vectors, graph, files, skills, mesh-MCPs, scheduled jobs). All shipped, all stay.
## What changes for users
- `~/.claude.json` simplifies: `"claudemesh": { "command": "claudemesh", "args": ["mcp"] }` becomes one entry per joined mesh after `claudemesh install`. Multi-mesh push works out of the box.
- ToolSearch loses ~80 deferred entries. Sessions are lighter.
- Scripts that called `mcp__claudemesh__*` get a deprecation warning in 1.x, break in 2.0 — replaced by `claudemesh <verb> --json` + `jq`.
- Claude Code system prompt for the MCP server gets shorter (no tool catalog), focused only on "RESPOND IMMEDIATELY to channel events."
## Open questions parked for future specs
- **Federation** — broker-to-broker encrypted relay so peers on different brokers can talk. Not in 2.0 scope.
- **Offline-with-TTL inbox** — persist `now` priority messages on broker if recipient is offline, with explicit TTL. Reasonable for 2.x.
- **Compute attribution** — when peer X invokes a mesh-MCP that peer Y deployed, who pays for broker compute / outbound calls? Pre-empts the eventual billing question. 2.x.
- **Universal hash-chained audit** — every state mutation per mesh is hash-chained, replayable, verifiable. Today only some events are; making it universal is its own spec.
- **ACP (Agent Communication Protocol) interop with Gemini CLI.** Gemini CLI exposes `--acp` for agent-to-agent comms — the same problem domain claudemesh occupies. Research question: is ACP a documented standard claudemesh can speak (making claudemesh peers and Gemini peers cross-talk in the same mesh), or is it Google-proprietary? If standard, implementing it is a major platform expansion. File as separate research spec before 2.x.
## What this spec is NOT
- Not a redesign of the broker. The broker stays as-is.
- Not a redesign of crypto. Crypto stays as-is.
- Not a feature deprecation. Every feature stays.
- Not optional. This is the canonical 2.0 architecture; intermediate versions migrate toward it.
## Effort estimate to 2.0
Sequential, single dev (revised after caveats survey — original estimate was rosy):
- **1.2.0** (socket bridge + field-JSON): 1-2 weeks. Socket bridge is real distributed-systems work (stale-cleanup, version negotiation, NFS/Windows edge cases) — not 2-3 days.
- **1.2.1** (claudemesh skill + topic shards): 2-3 days. Mostly content writing once schemas are documented.
- **1.3.0** (schedule unification + remaining verbs + stream-json): 1 week. Each of the ~10 missing verbs is small but adds up.
- **1.4.0** (resource-model rename + alias compat): 2-3 days.
- **1.5.0** (policy engine + MCP allowlist): 4-5 days. Policy engine is its own subsystem — parser, evaluator, audit log, admin override.
- **2.0.0** (strip tool handlers + cutover): 2 days.
Total: **~5-6 weeks of focused work** spread over 3-4 months calendar. Each release is independently shippable; the policy engine specifically can land later than 1.5 if needed.
## Acceptance signals — how we know it worked
- **ToolSearch** in a freshly-installed claudemesh session shows zero `mcp__claudemesh__*` entries by default (vs ~80 today).
- **`claudemesh peers --json name,status`** projects exactly two fields, no extra noise.
- **`claudemesh send <peer> "hi"`** from a Bash call inside a Claude session round-trips in <50ms (warm path via socket bridge) on localhost-broker, <250ms on EU-from-US.
- **`Skill: claudemesh`** loaded once teaches Claude the entire mesh surface; subsequent CLI calls require no further introspection.
- **A policy file with `decision: deny` for `file delete`** blocks the call before it hits the broker, with a clear stderr explanation.
- **`claudemesh status set working` from cron** opens its own WS (no daemon), succeeds in <1s, no orphan connections on broker.

View File

@@ -0,0 +1,155 @@
# claudemesh handoff — 2026-05-02 (evening)
Companion to the morning handoff (`2026-05-02-handoff.md`). Captures
what shipped through the v1.6.x patch line and the v1.7.0 demo cut.
Read before the next session.
---
## What shipped this evening
### v1.6.x patch line — closed except bridge smoke test
| Feature | Endpoint / file | Commit |
|---|---|---|
| SSE topic stream | `GET /api/v1/topics/:name/stream` | `7e71a61` |
| Unread counts | `PATCH /v1/topics/:name/read`, `unread` on `GET /v1/topics` | `a80eb6f` |
| Mesh-card unread badges | `apps/web/src/app/[locale]/dashboard/(user)/page.tsx` | `541440c` |
| Member sidebar | `GET /v1/members`, chat panel right rail | `a75483b` |
| SSE 4xx-stop fix | `apps/web/src/modules/mesh/topic-chat-panel.tsx` | `7af61e1` |
| Humans-as-peers | `GET /v1/peers` includes recent apikey users | `f4601f4` |
### v1.7.0 demo cut — 4 of 5 items shipped
| Item | Code | Commit |
|---|---|---|
| Member sidebar in chat | `apps/web/src/modules/mesh/topic-chat-panel.tsx` (+sidebar) | `a75483b` |
| Topic search + autocomplete | Same file (+ search toggle, mention dropdown, clay highlight) | `35a289b`, `00c25d9` |
| Notification feed | `MentionsSection` on universe + `GET /v1/notifications` | `a9160a0` |
| Public blog post | `apps/web/src/app/[locale]/(marketing)/blog/agents-and-humans-same-chat/` | `69cf39b` |
| Demo video script | `docs/demo-v1.7.0-script.md` (90s, 5 scenes) | `69cf39b` |
| Marketing site refresh | Timeline next-block updated | `a2ab7de` |
| **Recorded demo video** | — | **TODO (needs human + iTerm + Chrome)** |
| **Marketing screenshots** | — | **TODO (needs Chrome session)** |
### Roadmap state
- `docs/roadmap.md` updated. v1.6.x marks every endpoint shipped except
bridge smoke test. v1.7.0 marks sidebar/mentions/search/feed/blog
shipped; recording + screenshots open.
- v2.0.0 (daemon redesign) and v0.3.0 (operator layer / per-topic
encryption) untouched — both still architectural specs.
---
## Live status
- **Broker** (`wss://ic.claudemesh.com/ws`): autodeployed via Coolify
off the gitea-vps push. The custom migration runner from earlier
this session is the one moving migrations forward. No new
migrations shipped today — all v1.6.x work was code-only against
the v0.2.0 schema.
- **Web** (`claudemesh.com`): autodeployed via Vercel off the github
push. Verified `/v1/notifications`, `/v1/peers`, `/v1/members`,
`/v1/topics/general/stream`, `/v1/topics/general/read` all
return 401 with bad bearer (i.e. they exist + auth works).
Authenticated browser smoke not run — no Playwriter session
available during this handoff write.
- **CLI** (`claudemesh-cli@1.6.1` on npm): unchanged this session.
All v1.6.x work was server + web only; CLI doesn't yet consume
the new endpoints.
### CLI gap — worth noting
The new endpoints have NO CLI surface yet:
- `GET /v1/notifications``claudemesh notification list` could show
recent mentions in the terminal. ~30 LoC.
- `GET /v1/members``claudemesh member list` shows roster + online
state. Distinct from `peer list` which shows live sessions.
- `PATCH /v1/topics/:name/read` — could be implicit (called by
`topic show <name>`) or explicit (`claudemesh topic read <name>`).
- SSE stream — `claudemesh topic tail <name>` would tail messages
in the terminal. High demo value.
Wiring these is a small CLI release (v1.7.0). Not blocking anything
but worth doing before the recording so the demo includes a
"terminal tail" cut.
---
## Known issues / risks
1. **Mentions notification endpoint depends on plaintext-base64
ciphertext** that v0.2.0 ships. When per-topic encryption lands
in v0.3.0, both `GET /v1/notifications` and the universe-page
`MentionsSection` query break. Migration plan is documented in
the blog post + the inline comment: move to a
`mesh.notification` table populated at write time.
2. **Postgres `convert_from(decode(ciphertext, 'base64'), 'UTF8')`
throws on any ciphertext that isn't valid base64-of-UTF8.** All
current writers (broker WS path, REST POST /messages, web chat
panel) emit base64-of-plaintext-UTF8, so this works. If a future
writer emits binary ciphertext, the mention queries crash. Add a
safe-base64 guard or migrate to per-write notification table
before that happens.
3. **No live SSE smoke test in this session.** Endpoints respond
401 to bad bearer. Browser-authenticated test was deferred — no
Playwriter session was reachable during the run. Worth a
manual smoke before recording the demo.
4. **CSRF middleware blocks PATCH/POST without an Origin header.**
This is correct behaviour but trips up curl users. Documented
in the smoke notes; not a bug.
---
## Next session — three branches
### A. Record + ship the v1.7.0 launch (~2 hours, all human work)
1. Spin a fresh demo mesh + two iTerm panes running
`claudemesh launch --name Mou` and `--name Alexis`.
2. Run the demo script in `docs/demo-v1.7.0-script.md`.
3. Cut to 90s, upload to `claudemesh.com/media/demo-v170.mp4`.
4. Take 4-6 screenshots (universe, mesh detail, chat with sidebar,
mentions feed, mobile view) for the blog hero + Twitter card.
5. Cross-post per the script's distribution checklist.
### B. Wire CLI verbs to v1.6.x endpoints (~3 hours, code)
1. `claudemesh notification list [--since]``GET /v1/notifications`.
2. `claudemesh member list``GET /v1/members`.
3. `claudemesh topic tail <name>` → SSE consumer. Print as messages
arrive. Highest demo value.
4. `claudemesh topic read <name>``PATCH /v1/topics/:name/read`.
5. Bump `apps/cli/package.json` to 1.7.0, publish.
### C. v0.3.0 first slice — per-topic encryption (~5 hours, code)
This is the next architectural cut.
1. Schema: add `mesh.topic.encrypted_key` (encrypted-to-mesh-root).
2. Broker: derive symmetric key on first message via HKDF; cache.
3. Client: per-topic key fetch + `crypto_secretbox` over body.
4. `ciphertext` column stops being plaintext-base64 → mentions
query needs the notification table from issue #1.
Highest leverage right now is **A** (the recording is what turns
shipped code into shipped product), then **B** (CLI parity makes
the demo fuller). **C** is the next session for someone with
2+ uninterrupted hours.
---
## Repo state
- `main` ahead of `gitea-vps/main` and `github/main` by 0 commits
at handoff time — both pushed.
- 12 commits this evening session (sse → unread → grid → sidebar →
ssefix → mentions → search → notifications → roadmap → humans →
roadmap2 → blog+demo → timeline).
- No open PRs; everything went to main directly.
- No `.skip` / TODO files / temp commits left behind.
---
*Last handoff: this file. Previous: `2026-05-02-handoff.md` (morning).*

View File

@@ -0,0 +1,106 @@
# claudemesh handoff — 2026-05-02
State of the world after a long session that shipped 1.5.0 and the v0.2.0 backend. Read this before the next session — it captures what's done, what's deployed where, what's not, and the architectural decisions worth knowing.
---
## Where things stand
### Released to npm
- **`claudemesh-cli@1.5.0`** (latest tag, published earlier today). CLI-first architecture lock-in: zero-tool MCP, policy engine, bundled `claudemesh` skill. Verified install + smoke-tested via clean `npm i -g`.
### In `main` but NOT released yet
Everything below is committed, deployed to the broker (`wss://ic.claudemesh.com/ws`) and the web app (Vercel `claudemesh.com`), but **`claudemesh-cli@1.5.0` on npm doesn't have any of it**. Users won't see it until v1.6.0 publishes.
| Feature | Code path | Verified live? |
|---|---|---|
| Topics (schema, broker routing, CLI verbs, skill) | `packages/db/src/schema/mesh.ts`, `apps/broker/src/broker.ts`, `apps/cli/src/commands/topic.ts` | ✅ created `#deploys-test`, sent + persisted |
| `apikey create/list/revoke` (CLI + broker WS) | `apps/cli/src/commands/apikey.ts`, broker dispatch | ✅ full lifecycle exercised |
| REST `/api/v1/*` (messages, topics, peers, history) | `packages/api/src/modules/mesh/v1-router.ts` + `api-key-auth.ts` | ✅ posted via curl, history round-trips |
| Bridge peer (SDK + CLI) | `packages/sdk/src/bridge.ts`, `apps/cli/src/commands/bridge.ts` | ⚠️ code only — never run end-to-end |
### Architectural commitments locked this session
- **CLI-first, MCP push-pipe** (1.5.0): MCP `tools/list = []`. Inbound peer messages still arrive as `experimental.claude/channel` notifications. The bundled skill is the sole CLI-discoverability surface for Claude.
- **Topics complement groups, don't replace them** (v0.2.0): mesh = trust boundary, group = identity tag, topic = conversation scope. Three orthogonal axes.
- **Humans use REST + apikey, not browser WS** (v0.2.0): the broker already plumbs `peer_type: "human"`. The real blocker was browser-side ed25519, which we sidestep by exposing REST. Web chat UI = thin client over `/v1/*` using dashboard session auth.
- **Spec lives at**: `.artifacts/specs/2026-05-02-architecture-north-star.md` (1.5.0) and `.artifacts/specs/2026-05-02-v0.2.0-scope.md` (v0.2.0 cut + design sketches).
---
## Three pending sessions, ranked by leverage
### Session A — Ship v1.6.0 npm release (~30 min, highest leverage)
**Why first**: backend is feature-complete but unreleased. Users still get the no-topics 1.5.0.
Steps:
1. Bump `apps/cli/package.json` 1.5.0 → 1.6.0.
2. Update `apps/cli/README.md` migration note (mention topics, apikey, bridge).
3. Add `## v1.6.0` section to `docs/roadmap.md`.
4. Build + verify: `cd apps/cli && pnpm build && node dist/entrypoints/cli.js --version`.
5. `npm publish --tag latest --access public --no-git-checks --ignore-scripts`.
6. `git tag cli-v1.6.0 && git push github cli-v1.6.0` — workflow builds 5 binaries + auto-bumps Homebrew/winget tap.
7. Verify on a clean prefix: `PREFIX=/tmp/cm16 mkdir -p $PREFIX && npm install -g --prefix $PREFIX claudemesh-cli@1.6.0 && $PREFIX/bin/claudemesh --help | grep -E "topic|apikey|bridge"`.
### Session B — Migration drift fix (~1 day, highest pain reduction)
**Why second**: every schema change today requires manual `psql -f migration.sql` against prod. The drizzle `_journal.json` stops at idx 11, runtime migrator silently skips anything not in journal. Today's `0022_topics.sql` and `0023_api_keys.sql` were applied by hand. **Future migrations will keep needing this until fixed.**
Recommended approach:
1. Replace `drizzle-orm/postgres-js/migrator` in `apps/broker/src/migrate.ts` with a custom runner.
2. Scan `migrations/*.sql` lexicographically (already named `NNNN_*.sql`).
3. Track applied filenames in a new `mesh.__cmh_migrations` table (filename + sha256 + applied_at).
4. On startup: filter unapplied files, run them in transaction order under `pg_try_advisory_lock`. Fail loud on hash mismatch (catches edits after deploy).
5. Backfill the table with all 0000-0023 entries one-time so prod is consistent.
6. Drop the drizzle journal usage entirely (`migrations/meta/_journal.json` becomes dead state).
This unblocks every future feature touching DB.
### Session C — Web chat UI (~2-3 days, highest visibility)
**Why third**: the demo. Backend is ready; this is pure React + REST.
Path: `apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx` (new).
Components needed:
- Topic header (members count, settings button).
- Message stream — `GET /api/v1/topics/:name/messages?limit=50`. Poll every 5s for new (no WS yet — REST polling is fine for v0.2.0).
- Compose box — `POST /api/v1/messages` with `{topic, ciphertext, nonce}`.
- Members sidebar — `GET /api/v1/peers`.
- Apikey lifecycle: on first load, server-side issue an apikey for the dashboard user (using their existing NextAuth session) scoped to `read,send` on this topic. Stash in browser session storage.
Server-side helper for apikey issuance lives in `packages/api/src/modules/mesh/api-key-auth.ts` — refactor `verifyBearer` to also expose a `createApiKeyForUser(userId, meshId, scope)` helper for the dashboard handler.
---
## Three less-urgent followups (don't block sessions A-C)
1. **Bridge end-to-end smoke test**: never actually run between two meshes. Needs second test mesh + bridge member onboarding ritual. Worth doing before any blog post / external demo.
2. **`/v1/peers` includes only WS-connected agents**, not humans (since humans are REST-only and never appear in `presence`). Decide: synthetic presence rows for active apikey sessions? Or document that `/v1/peers` is "agents online"?
3. **Topic ciphertext is plaintext base64** in the current implementation — no actual encryption. The schema names it `ciphertext` for forward-compat, but the code base64-encodes UTF-8. Real per-topic symmetric key derivation (HKDF from mesh root_key + topic_id) is a v0.3.0 item.
---
## Production state worth knowing
- **Broker**: `wss://ic.claudemesh.com/ws`, deployed via Coolify on OVHcloud VPS. Auto-redeploys on push to `gitea-vps main`. Deploy ETA ~3 min.
- **Web**: `claudemesh.com`, Vercel auto-deploy on push to `github main`. Deploy ETA ~2 min.
- **Postgres**: container `eo1f5gydsgrg19b57e9s4zw7` on the VPS. SSH via `ssh ovh`, then `docker exec eo1f5gydsgrg19b57e9s4zw7 psql -U claudemesh -d claudemesh`.
- **Test mesh**: `openclaw` on the same broker has 5 active peers and one topic (`#deploys-test`).
- **Active apikey** (from earlier today's smoke): `cm_OC12dRti…` was revoked. None active right now.
---
## Files most worth reading first in next session
1. `.artifacts/specs/2026-05-02-architecture-north-star.md` — the 7 architectural commitments.
2. `.artifacts/specs/2026-05-02-v0.2.0-scope.md` — design sketches for topics, REST, bridge.
3. `apps/cli/skills/claudemesh/SKILL.md` — the canonical CLI surface; ships in npm tarball.
4. This file.
---
## Memory not yet captured
Worth adding to `~/.claude/projects/-Users-agutierrez-Desktop-claudemesh/memory/MEMORY.md` next session:
- **Drizzle journal drift is a recurring trap** — manual psql until session B lands. Save the exact apply ritual: `scp migrations/NNNN.sql ovh:/tmp/ && ssh ovh "docker cp /tmp/NNNN.sql <pg-container>:/tmp/ && docker exec <pg-container> psql -U claudemesh -d claudemesh -f /tmp/NNNN.sql"`.
- **`workspace:*` deps break `npm publish`** — keep SDK as devDependency in `apps/cli/package.json`; Bun bundles it into dist so runtime doesn't need it. Same trick for any other workspace-only build deps.
- **Commitlint hard-caps body lines at 100 chars** — use `git commit -F /tmp/cm-commit.txt` rather than `-m` heredocs. Heredocs that exceed the limit fail the husky hook silently.

View File

@@ -0,0 +1,227 @@
# claudemesh internal roadmap — 2026-05-02
Strategic counterpart to `docs/roadmap.md` (which is the public, marketing-tone roadmap). This file captures the *why*, the dependencies, the costs, and the things we deliberately won't do.
Anchored in the v0.2.0 backend cut + `#general` auto-creation + filename-tracked migrator + owner-member backfill that all shipped 2026-05-02.
---
## Forcing function
> **Ship v1.6.x in 2 weeks. Ship v1.7.0 in a month. Make the demo. Then commit the daemon.**
Each release stands on its own — usable and shippable even if the next slips. That's the property to optimize for, not "fastest path to v3.0.0."
---
## Schedule
| When | Version | Theme | Status |
|---|---|---|---|
| Now | 1.6.0 | v0.2.0 backend cut | ✅ shipped 2026-05-02 |
| +2w | 1.6.x | Demo polish (SSE, unread, sidebar) | Active |
| +5w | 1.7.0 | First marketing-ready version | Planned |
| +9w | 2.0.0 | Daemon redesign | Planned |
| +15w | 0.3.0 | Self-hosted + per-topic encryption + gateways | Planned |
| TBD | 3.0.0 | Native Claude channels | Anthropic-gated |
≈4 months from today to a teams-can-self-host shape. The MCP bridge stays load-bearing the whole time but stops being the user's problem at v2.0.0.
---
## v1.6.x patch line — 0-2 weeks, polish what's deployed
| Item | Effort | Why now |
|---|---|---|
| Real-time push (SSE on `/api/v1/topics/:name/stream`) | 2 days | Chat lag is the only user-visible v0.2.0 wart. Replaces 5s polling. |
| Unread counts via `last_read_at` | ½ day | Schema column already exists. PATCH on scroll-to-bottom + chip on topic list. |
| Bridge end-to-end smoke (two-mesh forwarding test) | ½ day | Feature shipped, never validated. Catches obvious bugs before any external demo. |
| Drizzle journal + `meta/` cleanup | 1 hour | Inert dead files since the new runner. Low-risk cosmetic. |
| `/v1/peers` includes humans (synthetic presence rows for active apikeys) | 1 day | Today the dashboard chat user is invisible to other peers. |
Total: ~1 week of focused work. Closes the v0.2.0 backend chapter cleanly.
---
## v1.7.0 — 2-3 weeks, the demo cut
The release that turns claudemesh into a thing you can record and show.
**Scope:**
- Member sidebar in the chat panel — names, online dots, presence summaries. Comes nearly free with SSE from v1.6.x.
- Topic search + member-mention autocomplete — `@Mou` hot-keys to `claudemesh send Mou ...`.
- Notification feed at `/dashboard` — "you have N unread in #deploys, 2 mentions in #incident." Purely aggregate; no new schema.
- One-line marketing site refresh — capture screenshots from the now-real-time UI, drop the v0.2.0 stamp from the chat footer, update README/landing.
- First public blog post + recorded demo — "claudemesh in 90 seconds" video. Triggers the first proper user-acquisition push.
**Not in scope:** any architectural change. v1.7.0 is pure UX polish on top of the v1.6.x foundation. Architecture work waits for v2.0.0.
**Why this comes before v2.0.0:** without users, the daemon is a solution for nobody. v1.7.0 produces the first real user signal so v2.0.0 has data to optimize against.
---
## v2.0.0 — 3-4 weeks, the daemon redesign
The single largest architectural shift on the roadmap. Background and rationale captured at length elsewhere this session; summary here.
### Single load-bearing principle
> **The user is the unit of mesh participation, not the Claude session.**
Every weird edge case from this session — the launch tax, the orphan owner, the per-session keypair churn, the MCP install/uninstall ritual, multi-Claude config corruption — comes from getting this one thing wrong today. Fix it once, structurally, and 70% of accumulated complexity vanishes.
### Architecture
```
claudemesh.com (web identity + workspace admin)
▼ JWT
broker (unchanged) — wss://ic.claudemesh.com/ws
▼ ws per workspace
claudemesh-daemon (per user, launchd/systemd, persistent)
▼ unix socket
┌────┴────┐
▼ ▼
CLI verbs MCP push-pipe (~50 LoC)
claude (any number of sessions)
```
### What v2.0.0 ships
- **`claudemesh-daemon`** — long-lived per-user process. One WS per workspace, kept alive across Claude session lifetimes. Listens on `~/.claudemesh/sockets/<workspace>.sock`. Started by `claudemesh login`, persists across reboots.
- **HKDF-derived peer keypairs from JWT** — same identity across machines, no key copy ritual. Web sign-up = CLI sign-up = same row in `mesh_member`.
- **Stateless CLI verbs** — each existing command (`send`, `peers`, `topic`, `apikey`, `bridge`, `state`, `remember`, etc.) retargeted to dial the daemon socket. ~3000 LoC of plumbing deleted, ~500 LoC of glue added.
- **50-line MCP server** — dial daemon, forward inbound peer messages as `experimental.claude/channel` notifications. The push-pipe shrinks from ~150 LoC to ~50.
- **`claudemesh launch` deprecated** — replaced by ambient mode: `claude` with no flags. Launch becomes a one-line alias that prints "ambient mode now, just run `claude`" and exits.
- **"Mesh" → "workspace"** in the public surface. DB tables keep `mesh_*` names for migration sanity.
### What v2.0.0 kills
- `claudemesh launch` command — the 8-thing bootstrap was paying for state the daemon now owns persistently.
- `--dangerously-skip-permissions` — set once at install in `settings.json` allowedTools, never seen by the user again.
- `--dangerously-load-development-channels` — written into `~/.claude.json` once at install, never seen again.
- Per-session `CLAUDEMESH_CONFIG_DIR` tmpdir — daemon owns config.
- Per-session `CLAUDEMESH_DISPLAY_NAME` env var — daemon stores it.
- MCP install/uninstall ritual on every launch — MCP entry is permanent.
- Multi-Claude config corruption — only the daemon writes config.
- Orphan-owner bug (just fixed via backfill) — structurally impossible because web sign-up creates the member row.
### What v2.0.0 keeps
- Wire protocol, crypto primitives, broker schema — 100% unchanged.
- All CLI verb names — 100% unchanged (just retargeted).
- REST `/api/v1/*` surface — 100% unchanged.
- Web chat UI — 100% unchanged.
- Bridge peer feature — 100% unchanged.
- Topic semantics, ciphertext field, ephemeral DMs — 100% unchanged.
### Cost
- ~3 weeks focused engineering
- ~30% LoC reduction in the CLI package
- ~80% reduction in support load for "launch flags," "config corruption," "peer keypair lost," "owner has no member row"
- ~0 cost to broker, web app, schema, protocol — none of the deep parts change
### Migration path (backwards-compatible at every step)
1. **Week 1** — daemon binary + unix socket protocol + retarget two CLI verbs (`send`, `peers`) as the smoke test. Ship to alpha testers.
2. **Week 2** — retarget remaining verbs. HKDF-keypair migration with a one-shot `claudemesh migrate-identity` command for existing users.
3. **Week 3**`claudemesh launch` becomes a deprecated alias. MCP server retargeted to daemon socket. Backfill: every existing user's daemon spins up on first `claudemesh` invocation.
4. **Cut v2.0.0**: remove deprecated launch alias one minor release later (v2.1.0) once metrics show no one's hitting it.
---
## v0.3.0 — 4-6 weeks, the operator chapter
For teams that want to run their own broker, encrypt at the topic level, or wire claudemesh to messaging surfaces beyond Claude Code.
- **Per-topic HKDF encryption** — kills the "broker can read your messages" wart. Symmetric key derived from `mesh.root_key + topic.id`. Web client gets the topic key from the sealed root_key it already holds.
- **Self-hosted broker packaging** — single `docker-compose.yml`, postgres included. CLI accepts `--broker wss://...` to point anywhere. Federation primer.
- **WhatsApp gateway** — peer bot that forwards a topic to a WhatsApp group.
- **Telegram gateway** — same pattern.
- **Tag routing** — `claudemesh send tag:repo:billing "deployed"` lands at every peer working on that repo. Already protocol-supported, needs CLI ergonomics + dashboard surface.
v0.3.0 is when teams that want to run their own broker can do so without paying us. Counterintuitively important: it's also when we can charge for hosted with a clean conscience.
---
## v3.0.0 — Anthropic-blessed cut (conditional)
Conditional on Anthropic shipping first-class agent-to-agent channels in Claude Code. We don't control the timing.
### What's load-bearing about today's flag
`--dangerously-load-development-channels server:claudemesh` does two things:
1. Loads the claudemesh MCP server.
2. Tells Claude Code to treat its `experimental.claude/channel` notifications as runtime channel events.
The flag is named `dangerously-load-development-channels` *specifically because* the channel API is experimental and unstable. Some opt-in mechanism will always be required for Claude Code to receive external events from a third-party process — that's a security-model invariant, not a quirk of today's flag. What changes at v3.0.0 is the *form* of the opt-in, not its existence.
### Two scenarios depending on Anthropic's choice
**Scenario A — MCP-channel API graduates.** The same MCP-based push primitive becomes stable.
- MCP wrapper stays (still translates `ws://broker → MCP notification`).
- The `--dangerously-load-development-channels` flag is replaced by a stable settings.json entry — e.g. `mcpServers.claudemesh.acceptChannelNotifications = true`.
- The `experimental.` prefix on the notification namespace goes away.
- Net user-visible change: nothing, because we already write the flag once at install and the user never sees it. The migration is internal: swap the install logic to write the new settings entry instead of the old flag.
**Scenario B — non-MCP transport ships.** Anthropic introduces a sidecar IPC, a native WebSocket subscription declared in settings, or some other primitive.
- The 50-line MCP wrapper from v2.0.0 disappears.
- The daemon plugs into the new transport directly.
- Some opt-in config is still required (settings.json entry, environment variable, etc.) — Claude Code must know to subscribe to the daemon's channel.
- Net user-visible change: still nothing if our `claudemesh install` adapts to write the new opt-in form.
### What disappears regardless
- The `experimental.` prefix on the channel API (it stabilizes).
- The `dangerously-` framing of the flag (the API is no longer experimental).
- The "you have to pass a launch flag to load development channels" mental model.
### What stays regardless
- An opt-in mechanism somewhere (security model invariant).
- The daemon as the lifecycle owner.
- The protocol, schema, broker, topics, web chat — all unchanged.
### Marketing pivot
claudemesh becomes a "hosted backend for Claude's native multi-agent feature" rather than a "Claude Code extension." The product story simplifies regardless of which shape ships, because the user no longer has to think about MCP servers, dangerous flags, or experimental APIs — claudemesh is just there.
Until v3.0.0 lands, v2.x ships with the MCP bridge under the existing flag. v3.0.0 is the migration target, not a planned feature.
---
## Cross-cutting tracks (always-on, not version-gated)
| Track | What it covers | Target version |
|---|---|---|
| Mobile | iOS peer app (thin: push + reply, same JWT identity) | v2.x |
| Browser peer (proper) | IndexedDB ed25519 + WebCrypto crypto_box for the dashboard. Today's web is REST-only; this makes it a true peer. | v2.x |
| Peer transcript queries | "Hey Claude2, what have you touched in the last hour?" cross-session memory primitive | v0.3.0+ |
| Mesh analytics | Volume, presence, handoff latency dashboards | v0.3.0 |
| Slack peer (first-party) | Today: build-your-own. Shipped natively. | v0.3.0 |
---
## Deliberate exclusions
| Idea | Why deferred |
|---|---|
| Custom bot framework / plugin marketplace | Premature — claudemesh barely has organic users. Build the user base first, then platform. |
| Voice channels | Out of scope. Different product. |
| Video chat | Same. |
| Email-as-peer (incoming SMTP → mesh) | Has demand from one user; ship if 3+ ask. |
| AI summarization of channels | LLM cost + scope creep. Users can wire their own with the existing message API. |
| Mobile push notifications via APNs/FCM | Wait for the iOS peer app, then revisit. |
| Reactions / threading | Not yet — would muddle the protocol surface for marginal value. Reconsider after v0.3.0 user feedback. |
---
## Single-sentence summary
**Polish v1.6.x → ship v1.7.0 demo → commit v2.0.0 daemon → open the operator chapter at v0.3.0 → plug into native channels at v3.0.0 when Anthropic ships them.** Each release stands on its own. The protocol, the schema, the broker, and the topics are all already correct — what changes is the lifecycle owner around them.

View File

@@ -0,0 +1,138 @@
# Topic-key onboarding — v0.3.0 phase 2
The schema for per-topic encryption is shipped (migration 0026). The
broker generates a 32-byte XSalsa20-Poly1305 key when a topic is
created and seals one copy for the creator via `crypto_box`. The open
question is **how new joiners get their sealed copy** without giving
the broker the plaintext.
This spec covers the three live options, picks one for v0.3.0 phase 2,
and parks the rest as future cuts. Implementation is **not in this
spec** — that follows once we ship the chosen flow.
---
## The constraint
The broker holds:
- `topic.encrypted_key_pubkey` — the ephemeral x25519 pubkey used to
seal each member's copy. Public. The matching secret is **discarded
immediately after creation** — only the topic creator's session
knows the topic key briefly during sealing, then it leaves memory.
- `topic_member_key.(encrypted_key, nonce)` — per-member sealed
ciphertext.
The broker **must not** be able to decrypt any sealed copy. So when a
new member joins a topic that already exists, the broker can't seal a
copy for them by itself.
## Option A — server-side escrow (REJECTED)
Broker holds the topic key encrypted under its own service key + per-
member sealed copies. Re-sealing for new members is a server-only
operation.
**Why rejected:** the broker can read every message in every topic
forever. Calling that "per-topic encryption" misleads users. Worse
than today's plaintext-base64 because it implies a security property
the design doesn't deliver.
## Option B — member-driven re-seal (CHOSEN for phase 2)
When a new member joins, an existing member's CLIENT decrypts their
own sealed copy of the topic key, then seals a new copy for the
joiner and POSTs it to the broker.
**Wire:**
1. New member joins via `claudemesh topic join <topic>` — broker
inserts `topic_member` row, no `topic_member_key` row.
2. New member calls `GET /v1/topics/:name/key` → 404 with
`key_not_sealed_for_member`.
3. Existing online members (any of them) periodically poll
`GET /v1/topics/:name/pending-seals` (new endpoint) and see the
new joiner.
4. Existing member's client:
- Decrypts their own sealed copy via `crypto_box_open` with their
x25519 secret + `topic.encrypted_key_pubkey`.
- Generates a fresh ephemeral x25519 keypair.
- Seals the topic key for the joiner via `crypto_box` with the
joiner's pubkey + the new ephemeral.
- POSTs the result to `POST /v1/topics/:name/seal`.
5. Broker stores the new `topic_member_key` row.
6. New member's `GET /v1/topics/:name/key` now returns 200.
**Trust model:** broker never sees plaintext. Assumes at least one
existing member is online when the joiner connects. Worst case the
joiner waits — UI shows "waiting for a peer to share the topic key"
until somebody seals.
**Open detail — sender pubkey identity:** each re-seal uses a fresh
ephemeral pubkey. Either:
(a) Store ALL ephemeral pubkeys ever used to seal copies of this
topic, indexed by member, so the joiner can pick the right one
when decrypting. Adds a new table.
(b) Embed the ephemeral pubkey in the sealed payload itself (
`encrypted_key` becomes `<32-byte ephem_pubkey><crypto_box_easy>`).
Decoder pulls the prefix, uses it as the sender pubkey. No schema
change beyond what 0026 already ships.
(b) wins on simplicity. Phase 2 implementation uses it.
## Option C — leaderless protocol (DEFERRED)
MLS, TreeKEM, or similar continuous group key agreement. Right answer
for groups >50 members. Overkill for v0.3.0 — implementation cost is
4-6 weeks of focused work, and the threat model gain over Option B
only matters if we believe a member's machine can be silently
compromised long enough to leak the topic key but short enough that
they aren't kicked from the topic.
Park for v0.4.0 or v0.5.0. Revisit when we onboard a customer that
asks for FS (forward secrecy) on group chat.
---
## Phase-2 implementation checklist
Schema (0026 — done):
- [x] `topic.encrypted_key_pubkey` (legacy field, will be unused in
Option B's "embed in payload" mode, but keeping it for
forward-compat if we ever switch to Option C)
- [x] `topic_member_key.(encrypted_key, nonce)`
- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext)
API (some done — see annotations):
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys
- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy
Broker:
- [x] `createTopic` generates topic key + seals for creator
- [ ] `joinTopic` becomes a "pending" insert — no key seal
- [ ] (optional) WS notification to online topic members when a new
joiner arrives, so re-seal latency is sub-second instead of
polling-bound
Client (CLI + web):
- [ ] On topic open, fetch sealed key, decrypt + cache in memory
- [ ] On send, encrypt body with topic key, set `body_version: 2`
- [ ] On render, decrypt v2 messages with cached key; v1 stays
base64 plaintext (legacy)
- [ ] Background re-seal loop — poll for pending joiners, seal,
POST
UX:
- [ ] "waiting for a peer to share the topic key" state when GET key
returns 404
- [ ] "you are the only online member — joiners can't read messages
until someone else logs in" warning when sole online holder
goes offline
The phase-2 commit ships only the schema + creator-seal + GET /key.
The pending-seals endpoint, seal POST, and client encryption land in
phase 3 once this spec gets a code review. Mention fan-out from
phase 1 already works for both v1 and v2 messages, so /v1/notifications
keeps working through the cutover.

View File

@@ -0,0 +1,273 @@
# claudemesh v0.2.0 — scope
**Date:** 2026-05-02
**Status:** draft
**Predecessor:** [`2026-05-02-architecture-north-star.md`](./2026-05-02-architecture-north-star.md) (1.5.0 architecture lock)
---
## Cut
**Theme: from agent-only mesh to mesh of agents, humans, and external systems — with conversation context.**
| # | Feature | Effort | Spine |
|---|---------|--------|-------|
| 1 | **Topics** (channels/rooms within a mesh) | 2-3 d | yes |
| 2 | **Humans in the mesh** (web chat panel) | 2-3 d | depends on #1 |
| 3 | **REST API + external WS** (API keys per mesh) | 2-3 d | depends on #1 |
| 4 | **Bridge peer** (forwards one topic between meshes) | 1 d | depends on #1 |
Optional pickup if all four ship early:
- **Local peer aliases** (~0.5 d) — IRC-style local labels for hard-to-remember displayNames.
- **Semantic peer search** (~0.5 d) — already in vision doc; useful once topics exist.
Total: 7-9 days plus 1-2 days slack. Targeting **release window: 2026-05-12 to 2026-05-16**.
---
## Why this cut
The 1.5.0 architecture (CLI-first, tool-less MCP, policy engine) is finished. The next bottleneck is **product surface**, not engineering.
Current taxonomy `mesh + group + role` is the right *organizational* structure but missing a *conversational* primitive. Every message is DM or `@group` broadcast — there's no continuity for "the deploys conversation," no scoped state/memory/files, no way for a human to join a topic without joining the whole mesh, no way for a bridge to forward a single thread of work.
**Topics fix this.** They are the spine of v0.2.0:
- Without topics, "humans in mesh" floods every human with every peer's chatter.
- Without topics, "bridge" forwards everything (loop risk, signal-to-noise problem).
- Without topics, REST API endpoints have no natural sub-mesh scope.
Once topics exist, humans + REST + bridge each become 50% smaller because they slot into a clean primitive instead of inventing one.
---
## Deferred
| Item | Why later |
|---|---|
| **Federation** (broker-to-broker) | Bridges prototype it. Learn from real use first. |
| **Sandboxes** (E2B / Modal) | Orthogonal capability. Separate release. |
| **Sim SDK** (`@claudemesh/sim`) | Niche audience; long-tail. v0.3.0+. |
| **Welcome back / persistent MCP** | Already in progress as 1.6.0 patch. |
| **Mesh telemetry** | Pre-PMF telemetry is busywork; users first. |
---
## Design sketches
### 1. Topics
**Mental model:** mesh is *who you trust*; group is *who you are*; topic is *what you're talking about*. Three orthogonal axes.
**Wire shape:**
```yaml
topic:
id: <ulid>
mesh_slug: openclaw
name: deploys # unique within mesh
description: "deploy + on-call"
visibility: public # public | private (invite-only) | dm (1:1, autocreated)
created_by: <pubkey>
created_at: <ts>
```
**Membership:**
```yaml
topic_member:
topic_id: <ulid>
pubkey: <hex> # session pubkey OR member_pubkey for durable identity
role: lead | member | observer
joined_at: <ts>
last_read_at: <ts> # for unread counts
```
**Messages reference a topic, not just a target:**
```jsonc
// existing send_message envelope gains a `topic` field
{
"to": "@deploys", // or topic id, or peer name (DM)
"topic": "deploys", // optional explicit, inferred from `to: @<topic>`
"message": "...",
"priority": "next"
}
```
**Resolution rules:**
- `to: "alice"` → DM to peer alice (no topic).
- `to: "@frontend"` → group broadcast (no topic — backwards compatible with 1.5.0).
- `to: "#deploys"` → topic message; delivered only to topic subscribers.
- `to: "*"` → mesh-wide broadcast (kept; lower-priority than topic for new comms).
**State/memory/files scoping:**
- `claudemesh state set <k> <v> --topic deploys` — namespace under topic.
- `claudemesh remember "..." --topic deploys` — topic-scoped memory.
- `claudemesh file list --topic deploys` — files visible only to topic members.
**CLI:**
```bash
claudemesh topic create deploys --description "deploy + on-call"
claudemesh topic list # all topics in mesh
claudemesh topic join deploys
claudemesh topic leave deploys
claudemesh topic invite deploys <peer> # private topics
claudemesh topic members deploys
claudemesh topic delete deploys # creator/admin only
claudemesh send "#deploys" "rolling out 1.5.1"
```
**MCP `claude/channel` notification gains `topic`** as an attribute so peers know which conversation an inbound message belongs to.
**Effort breakdown:** schema + drizzle migration + CLI verbs + broker routing changes (filter by topic membership) + skill update. ~250 LoC across CLI + ~200 LoC broker.
---
### 2. Humans in the mesh
**Mental model:** a human is a peer with `peer_type: "human"` whose presence is durable (no session pubkey rotation; identity tied to an account). They join *topics*, not the whole mesh — so they only see relevant traffic.
> **Implementation update (2026-05-02):** `peer_type: "ai" | "human" | "connector"` is already plumbed end-to-end in the broker (hello envelope, ConnectedPeer, list_peers). What was missing wasn't broker support — it's the **interface** for humans, who don't have browser-side ed25519 to do hello-sig. Realistic path: **REST API is the human interface** (rolled into #3 below). The web chat panel becomes a thin client that posts/reads via REST using the dashboard user's session auth — not its own keypair. This collapses #2 and #3 into a single deliverable: REST → UI on top.
**Wire:**
```jsonc
// hello envelope gains:
{
"peer_type": "human",
"session_pubkey": <ephemeral, per browser tab>,
"member_pubkey": <durable, account-tied>,
"display_name": "Alejandro"
}
```
**Web panel (`apps/web`):**
```
/dashboard/mesh/<slug>/topic/<topic-name>
├── topic header (members, settings)
├── message stream (WS-driven, infinite scroll on history)
├── compose box (typing indicator broadcast on focus)
└── members sidebar (presence, profile, last_read_at)
```
**Backend changes:**
- Persistent message history per topic (drizzle table `topic_messages`; existing direct messages stay ephemeral by design).
- Topic-scoped read receipts (`topic_member.last_read_at`).
- Typing indicator: short-lived broadcast on the topic channel (`{type: "typing", peer: "..."}`).
**Privacy invariant:** a human in `#deploys` sees only `#deploys` traffic + DMs sent to them. Never the whole mesh. This is the *whole reason* topics come first.
**Effort:** WS endpoint already exists (broker side). Add: topic_messages table, history endpoint, web UI components (compose, stream, members). ~3 days.
---
### 3. REST API + external WS
**Auth:** API keys per mesh, scoped by capability + topic.
```yaml
api_key:
id: <ulid>
mesh_slug: openclaw
label: "ci-bot"
hash: <argon2id>
capabilities: ["send", "read"]
topic_scopes: ["#deploys"] # null = all topics; explicit = whitelist
created_at: <ts>
last_used_at: <ts>
revoked_at: <ts | null>
```
**CLI for issuance (admin only):**
```bash
claudemesh apikey create --label "ci-bot" --topic deploys --cap send,read
claudemesh apikey list
claudemesh apikey revoke <id>
```
**REST endpoints (claudemesh.com/api/v1):**
```
POST /v1/messages Send a message (auth: api key).
GET /v1/topics/:name/messages History (with pagination cursor).
GET /v1/peers List online peers (filtered by key scope).
GET /v1/state Read mesh state.
POST /v1/state Write mesh state.
```
**External WS:** `wss://ic.claudemesh.com/ws?api_key=...&topic=deploys` — connects with `peer_type: "external"`. Push-pipe parity with internal sessions; can subscribe to topic streams.
**Why REST keys not session keypairs:** external clients (Zapier, GitHub Actions, mobile apps, Slack workspace bots) need long-lived bearer-like creds, not ephemeral keypairs. Different threat model — scope tightly via topic + capability.
**Effort:** ~3 days. Mostly broker work; CLI gets the issuance verbs.
---
### 4. Bridge peer
**Mental model:** a bridge is a peer that holds memberships in two meshes and forwards traffic on a single topic between them. SDK-only (no broker changes).
**Implementation (uses existing `@claudemesh/sdk`):**
```typescript
import { Bridge } from "@claudemesh/sdk";
const bridge = new Bridge({
meshes: ["work", "external"],
topic: "incidents",
filter: (msg) => !msg.tags.includes("internal-only"),
loop_prevention: { tag: "via-bridge", max_hops: 2 },
});
await bridge.start();
```
**Loop prevention:** every forwarded message gets a `bridge_hop_<n>` tag; bridges drop messages that already carry their own tag (prevents echo) and any message with `max_hops` exceeded.
**CLI:** `claudemesh bridge run <config.yaml>` — runs an SDK bridge as a long-lived process. Useful for "run a bridge inside a docker container or systemd unit."
**What it deliberately doesn't do:**
- Cross-broker federation (that's a separate broker-to-broker protocol).
- Bidirectional state/memory sync (only messages on a single topic).
- Identity unification (a peer in mesh A is *not* the same peer in mesh B; the bridge appears as the messenger).
**Effort:** ~1 day on top of the existing SDK.
---
## Acceptance signals
v0.2.0 ships when all four are demonstrable end-to-end:
1. A peer creates `#deploys`, two other peers join it, traffic is topic-scoped, mesh-wide chat doesn't see it.
2. A human signs in at `claudemesh.com`, joins `#deploys`, sends a message, a Claude session in the mesh receives it as a `<channel>` interrupt with `topic="deploys"`.
3. A `curl` POST against `/v1/messages` with an API key delivers a message into `#deploys`; the same API key is rejected on `#secrets`.
4. A bridge peer running locally forwards `#incidents` between two test meshes; loop is prevented; one-shot demo recorded.
---
## Out of scope (explicitly)
- Topic hierarchy / nesting (flat namespace per mesh; revisit at scale).
- Topic-scoped capability grants (`grant <peer> read:#topic`) — solvable later via capability extension.
- Threads-within-topics (Slack-style). Defer.
- Voice / video / file-upload UX for humans — text only in v0.2.0.
- Federation, sandboxes, sim-sdk — explicitly deferred above.
---
## Risks
- **Topics retrofit risk** — existing 1.5.0 message envelope assumes "to" is peer/group/star. Adding `topic` is additive on the wire but changes routing logic. Test path: backfill existing meshes with a default `#general` topic; opt-in to topic-only routing.
- **Web chat session lifecycle** — humans expect "I closed the tab and came back, my place is preserved." Ephemeral session pubkeys break that. Workaround: tie human peer identity to `member_pubkey` + last_read_at on the topic; session pubkey rotates per tab but membership is durable.
- **API key abuse** — leaked keys = anyone can post. Mitigations: capability + topic scoping; rate limits per key; `last_used_at` + audit trail; revoke verb is fast.
---
## Open questions
1. Do existing `@group` semantics survive intact, or do we collapse `@group` and `#topic` into one primitive? (Answer favored: keep both — different axes.)
2. Should topics persist messages by default, or be opt-in? (Default: yes for `peer_type: "human"`-touched topics; configurable per topic for agent-only ones.)
3. Where does mesh-MCP discovery live in the topic model — per topic or per mesh? (Likely per mesh; mesh-MCP is infrastructure, not conversation.)

View File

@@ -0,0 +1 @@
{"sessionId":"ae5dbe38-9c56-4d07-9fb6-a38cb8a250a6","pid":4612,"acquiredAt":1776217467441}

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up:*)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up session:*)",
"Bash(npx tsx:*)",
"Bash(grep -r \"defineCommand\\\\|export const run\" /Users/agutierrez/Desktop/claudemesh/apps/cli/src/commands/*.ts)",
"Bash(pnpm build:*)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Ready to help:*)",
"Bash(pnpm publish:*)",
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Investigating dropped keystrokes in claudemesh launch:*)",
"Read(//Users/agutierrez/.claude/**)",
"Read(//private/tmp/**)",
"Bash(timeout 3 node dist/index.js mcp)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Fixed ZodError in MCP notification handler:*)",
"Bash(npm i:*)",
"Bash(claudemesh --version)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh:*)"
]
}
}

View File

@@ -0,0 +1,58 @@
---
name: integration-nextjs-app-router
description: PostHog integration for Next.js App Router applications
metadata:
author: PostHog
version: 1.9.5
---
# PostHog integration for Next.js App Router
This skill helps you add PostHog analytics to Next.js App Router applications.
## Workflow
Follow these steps in order to complete the integration:
1. `basic-integration-1.0-begin.md` - PostHog Setup - Begin ← **Start here**
2. `basic-integration-1.1-edit.md` - PostHog Setup - Edit
3. `basic-integration-1.2-revise.md` - PostHog Setup - Revise
4. `basic-integration-1.3-conclude.md` - PostHog Setup - Conclusion
## Reference files
- `references/EXAMPLE.md` - Next.js App Router example project code
- `references/next-js.md` - Next.js - docs
- `references/identify-users.md` - Identify users - docs
- `references/basic-integration-1.0-begin.md` - PostHog setup - begin
- `references/basic-integration-1.1-edit.md` - PostHog setup - edit
- `references/basic-integration-1.2-revise.md` - PostHog setup - revise
- `references/basic-integration-1.3-conclude.md` - PostHog setup - conclusion
The example project shows the target implementation pattern. Consult the documentation for API details.
## Key principles
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
## Framework guidelines
- For Next.js 15.3+, initialize PostHog in instrumentation-client.ts for the simplest setup
- For feature flags, use useFeatureFlagEnabled() or useFeatureFlagPayload() hooks - they handle loading states and external sync automatically
- Add analytics capture in event handlers where user actions occur, NOT in useEffect reacting to state changes
- Do NOT use useEffect for data transformation - calculate derived values during render instead
- Do NOT use useEffect to respond to user events - put that logic in the event handler itself
- Do NOT use useEffect to chain state updates - calculate all related updates together in the event handler
- Do NOT use useEffect to notify parent components - call the parent callback alongside setState in the event handler
- To reset component state when a prop changes, pass the prop as the component's key instead of using useEffect
- useEffect is ONLY for synchronizing with external systems (non-React widgets, browser APIs, network subscriptions)
## Identifying users
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
## Error tracking
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.

View File

@@ -0,0 +1,706 @@
# PostHog Next.js App Router Example Project
Repository: https://github.com/PostHog/context-mill
Path: basics/next-app-router
---
## README.md
# PostHog Next.js app router example
This is a [Next.js](https://nextjs.org) App Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
## Features
- **Product analytics**: Track user events and behaviors
- **Session replay**: Record and replay user sessions
- **Error tracking**: Capture and track errors
- **User authentication**: Demo login system with PostHog user identification
- **Server-side & Client-side tracking**: Examples of both tracking methods
- **Reverse proxy**: PostHog ingestion through Next.js rewrites
## Getting started
### 1. Install dependencies
```bash
npm install
# or
pnpm install
```
### 2. Configure environment variables
Create a `.env.local` file in the root directory:
```bash
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
Get your PostHog project token from your [PostHog project settings](https://app.posthog.com/project/settings).
### 3. Run the development server
```bash
npm run dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the app.
## Project structure
```
src/
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── login/
│ │ └── route.ts # Login API with server-side tracking
│ ├── burrito/
│ │ └── page.tsx # Demo feature page with event tracking
│ ├── profile/
│ │ └── page.tsx # User profile with error tracking demo
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Home/Login page
│ └── globals.css # Global styles
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
└── lib/
└── posthog-server.ts # Server-side PostHog client
instrumentation-client.ts # Client-side PostHog initialization
```
## Key integration points
### Client-side initialization (instrumentation-client.ts)
```typescript
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});
```
### User identification (AuthContext.tsx)
```typescript
posthog.identify(username, {
username: username,
});
```
### Event tracking (burrito/page.tsx)
```typescript
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});
```
### Error tracking (profile/page.tsx)
```typescript
posthog.captureException(error);
```
### Server-side tracking (app/api/auth/login/route.ts)
```typescript
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: { ... }
});
```
## App router differences from pages router
This example uses Next.js App Router instead of Pages Router. Key differences:
1. **File-based routing**: Pages in `src/app/` instead of `src/pages/`
2. **layout.tsx**: Root layout component wraps all pages
3. **API Routes**: Located in `src/app/api/` with `route.ts` files
4. **'use client'**: Client components need explicit directive
5. **useRouter**: From `next/navigation` instead of `next/router`
6. **Metadata**: Exported from layout/page instead of Head component
7. **Server Components**: Components are server-side by default
## Learn more
- [PostHog Documentation](https://posthog.com/docs)
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
- [PostHog Next.js Integration Guide](https://posthog.com/docs/libraries/next-js)
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new).
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
---
## .env.example
```example
# PostHog Configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
---
## instrumentation-client.ts
```ts
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
// Include the defaults option as required by PostHog
defaults: '2026-01-30',
// Enables capturing unhandled exceptions via Error Tracking
capture_exceptions: true,
// Turn on debug in development mode
debug: process.env.NODE_ENV === "development",
});
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.
```
---
## next.config.ts
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
```
---
## src/app/api/auth/login/route.ts
```ts
import { NextResponse } from 'next/server';
import { getPostHogClient } from '@/lib/posthog-server';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export async function POST(request: Request) {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
username: username,
isNewUser: isNewUser,
source: 'api'
}
});
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
});
return NextResponse.json({ success: true, user });
}
```
---
## src/app/burrito/page.tsx
```tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth();
const router = useRouter();
const [hasConsidered, setHasConsidered] = useState(false);
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const handleConsideration = () => {
incrementBurritoConsiderations();
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
```
---
## src/app/layout.tsx
```tsx
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import Header from "@/components/Header";
export const metadata: Metadata = {
title: "Burrito Consideration App",
description: "Consider the potential of burritos",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<AuthProvider>
<Header />
<main>{children}</main>
</AuthProvider>
</body>
</html>
);
}
```
---
## src/app/page.tsx
```tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are now logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
```
---
## src/app/profile/page.tsx
```tsx
'use client';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You&apos;ve considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You&apos;re getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You&apos;re becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
```
---
## src/components/Header.tsx
```tsx
'use client';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-container">
<nav>
<Link href="/">Home</Link>
{user && (
<>
<Link href="/burrito">Burrito Consideration</Link>
<Link href="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
```
---
## src/contexts/AuthContext.tsx
```tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
```
---
## src/lib/posthog-server.ts
```ts
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
}
);
posthogClient.debug(true);
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}
```
---

View File

@@ -0,0 +1,43 @@
---
title: PostHog Setup - Begin
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
---
We're making an event tracking plan for this project.
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
Look for opportunities to track client-side events.
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
- Payment/checkout completion
- Webhook handlers
- Authentication endpoints
Do not skip server-side events - they capture actions that cannot be tracked client-side.
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add: event name, event description, and the file path we want to place the event in. If events already exist, don't duplicate them; supplement them.
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
## Status
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
[STATUS] Checking project structure.
Status to report in this phase:
- Checking project structure
- Verifying PostHog dependencies
- Generating events based on project
---
**Upon completion, continue with:** [basic-integration-1.1-edit.md](basic-integration-1.1-edit.md)

View File

@@ -0,0 +1,37 @@
---
title: PostHog Setup - Edit
description: Implement PostHog event tracking in the identified files, following best practices and the example project
---
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
You should also add PostHog exception capture error tracking to these files where relevant.
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
Remember the documentation and example project resources you were provided at the beginning. Read them now.
## Status
Status to report in this phase:
- Inserting PostHog capture code
- A status message for each file whose edits you are planning, including a high level summary of changes
- A status message for each file you have edited
---
**Upon completion, continue with:** [basic-integration-1.2-revise.md](basic-integration-1.2-revise.md)

View File

@@ -0,0 +1,22 @@
---
title: PostHog Setup - Revise
description: Review and fix any errors in the PostHog integration implementation
---
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
Ensure that any components created were actually used.
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
## Status
Status to report in this phase:
- Finding and correcting errors
- Report details of any errors you fix
- Linting, building and prettying
---
**Upon completion, continue with:** [basic-integration-1.3-conclude.md](basic-integration-1.3-conclude.md)

View File

@@ -0,0 +1,38 @@
---
title: PostHog Setup - Conclusion
description: Review and fix any errors in the PostHog integration implementation
---
Use the PostHog MCP to create a new dashboard named "Analytics basics" based on the events created here. Make sure to use the exact same event names as implemented in the code. Populate it with up to five insights, with special emphasis on things like conversion funnels, churn events, and other business critical insights.
Search for a file called `.posthog-events.json` and read it for available events. Do not spawn subagents.
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, along with a list of links for the dashboard and insights created. Follow this format:
<wizard-report>
# PostHog post-wizard report
The wizard has completed a deep integration of your project. [Detailed summary of changes]
[table of events/descriptions/files]
## Next steps
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
[links]
### Agent skill
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
</wizard-report>
Upon completion, remove .posthog-events.json.
## Status
Status to report in this phase:
- Configured dashboard: [insert PostHog dashboard URL]
- Created setup report: [insert full local file path]

View File

@@ -0,0 +1,202 @@
# Identify users - Docs
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
However, in the frontend of a [web](/docs/libraries/js/features.md#capturing-events) or [mobile app](/docs/libraries/ios.md#capturing-events), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features.md#capturing-anonymous-events).
To link events to specific users, call `identify`:
PostHog AI
### Web
```javascript
posthog.identify(
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
);
```
### Android
```kotlin
PostHog.identify(
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
// optional: set additional person properties
userProperties = mapOf(
"name" to "Max Hedgehog",
"email" to "max@hedgehogmail.com"
)
)
```
### iOS
```swift
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
```
### React Native
```jsx
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
email: 'max@hedgehogmail.com', // optional: set additional person properties
name: 'Max Hedgehog'
})
```
### Dart
```dart
await Posthog().identify(
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
userProperties: {
email: "max@hedgehogmail.com", // optional: set additional person properties
name: "Max Hedgehog"
});
```
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
## How identify works
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users even across different sessions.
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
Using identify in the backend
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
## Best practices when using `identify`
### 1\. Call `identify` as soon as you're able to
In your frontend, you should call `identify` as soon as you're able to.
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
This ensures that events sent during your users' sessions are correctly associated with them.
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
### 2\. Use unique strings for distinct IDs
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
PostHog also has built-in protections to stop the most common distinct ID mistakes.
### 3\. Reset after logout
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
You can do that like so:
PostHog AI
### Web
```javascript
posthog.reset()
```
### iOS
```swift
PostHogSDK.shared.reset()
```
### Android
```kotlin
PostHog.reset()
```
### React Native
```jsx
posthog.reset()
```
### Dart
```dart
Posthog().reset()
```
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
Web
PostHog AI
```javascript
posthog.reset(true)
```
### 4\. Person profiles and properties
You'll notice that one of the parameters in the `identify` method is a `properties` object.
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
Person properties can also be set being adding a `$set` property to a event `capture` call.
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
### 5\. Use deep links between platforms
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
- Onboarding and signup flows before authentication.
- Unauthenticated web pages redirecting to authenticated mobile apps.
- Authenticated web apps prompting an app download.
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
3. When the user is redirected to the app, parse the deep link and handle the following cases:
- The user is already authenticated on the mobile app. In this case, call [`posthog.alias()`](/docs/libraries/js/features.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person.
- The user is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features.md#identifying-users) with the distinct ID from the web. Events will be associated with this distinct ID.
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
## Further reading
- [Identifying users docs](/docs/product-analytics/identify.md)
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better

View File

@@ -0,0 +1,385 @@
# Next.js - Docs
PostHog makes it easy to get data about traffic and usage of your [Next.js](https://nextjs.org/) app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Next.js app using the [React](/docs/libraries/react.md) and the [Node.js](/docs/libraries/node.md) SDKs.
> You can see a working example of this integration in our [Next.js demo app](https://github.com/PostHog/posthog-js/tree/main/playground/nextjs).
Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.
> **Try `@posthog/next` (pre-release):** A simplified Next.js integration with synchronized client/server identity, server-side flag bootstrapping, and a built-in API proxy. [Read the setup guide →](/docs/libraries/next-js/posthog-next.md)
## Prerequisites
To follow this guide along, you need:
1. A PostHog instance (either [Cloud](https://app.posthog.com/signup) or [self-hosted](/docs/self-host.md))
2. A Next.js application
## Beta: integration via LLM
Install PostHog for Next.js in seconds with our wizard by running this prompt with [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
`npx @posthog/wizard@latest`
[Learn more](/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
## Client-side setup
Install `posthog-js` using your package manager:
PostHog AI
### npm
```bash
npm install --save posthog-js
```
### Yarn
```bash
yarn add posthog-js
```
### pnpm
```bash
pnpm add posthog-js
```
### Bun
```bash
bun add posthog-js
```
Add your environment variables to your `.env.local` file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your [project settings](https://app.posthog.com/project/settings).
.env.local
PostHog AI
```shell
NEXT_PUBLIC_POSTHOG_TOKEN=<ph_project_token>
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
These values need to start with `NEXT_PUBLIC_` to be accessible on the client-side.
## Integration
Next.js provides the [`instrumentation-client.ts|js`](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client) file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:
PostHog AI
### instrumentation-client.js
```javascript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
### instrumentation-client.ts
```typescript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
Bootstrapping with `instrumentation-client`
When using `instrumentation-client`, the values you pass to `posthog.init` remain fixed for the entire session. This means bootstrapping only works if you evaluate flags **before your app renders** (for example, on the server).
If you need flag values after the app has rendered, youll want to:
- Evaluate the flag on the server and pass the value into your app, or
- Evaluate the flag in an earlier page/state, then store and re-use it when needed.
Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same `distinct_id` across client and server.
See the [bootstrapping guide](/docs/feature-flags/bootstrapping.md) for more information.
## Identifying users
> **Identifying users is required.** Call `posthog.identify('your-user-id')` after login to link events to a known user. This is what connects frontend event captures, [session replays](/docs/session-replay.md), [LLM traces](/docs/ai-engineering.md), and [error tracking](/docs/error-tracking.md) to the same person — and lets backend events link back too.
>
> See our guide on [identifying users](/docs/getting-started/identify-users.md) for how to set this up.
Set up a reverse proxy (recommended)
We recommend [setting up a reverse proxy](/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our [own managed reverse proxy service](/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using [Cloudflare](/docs/advanced/proxy/cloudflare.md), [AWS Cloudfront](/docs/advanced/proxy/cloudfront.md), and [Vercel](/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and [group them in one project](/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like [heatmaps](/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHogs requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
**EU**: `3.75.65.221`, `18.197.246.42`, `3.120.223.253`
**US**: `44.205.89.55`, `52.4.194.122`, `44.208.188.173`
These are public, stable IPs used by PostHog services (e.g., Celery tasks for snapshots).
## Accessing PostHog
Once initialized in `instrumentation-client.js|ts`, import `posthog` from `posthog-js` anywhere and call the methods you need on the `posthog` object.
JavaScript
PostHog AI
```javascript
'use client'
import posthog from 'posthog-js'
export default function Home() {
return (
<div>
<button onClick={() => posthog.capture('test_event')}>
Click me for an event
</button>
</div>
);
}
```
### Using React hooks
The [React feature flag hooks](/docs/libraries/react.md#feature-flags) work automatically when PostHog is initialized via `instrumentation-client.ts`. The hooks use the initialized posthog-js singleton:
JavaScript
PostHog AI
```javascript
'use client'
import { useFeatureFlagEnabled } from 'posthog-js/react'
export default function FeatureComponent() {
const showNewFeature = useFeatureFlagEnabled('new-feature')
return showNewFeature ? <NewFeature /> : <OldFeature />
}
```
### Usage
See the [React SDK docs](/docs/libraries/react.md) for examples of how to use:
- [`posthog-js` functions like custom event capture, user identification, and more.](/docs/libraries/react.md#using-posthog-js-functions)
- [Feature flags including variants and payloads.](/docs/libraries/react.md#feature-flags)
You can also read [the full `posthog-js` documentation](/docs/libraries/js/features.md) for all the usable functions.
## Server-side analytics
Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the [Node SDK](/docs/libraries/node.md).
First, install the `posthog-node` library:
PostHog AI
### npm
```bash
npm install posthog-node --save
```
### Yarn
```bash
yarn add posthog-node
```
### pnpm
```bash
pnpm add posthog-node
```
### Bun
```bash
bun add posthog-node
```
### Router-specific instructions
## App router
For the app router, we can initialize the `posthog-node` SDK once with a `PostHogClient` function, and import it into files.
This enables us to send events and fetch data from PostHog on the server without making client-side requests.
JavaScript
PostHog AI
```javascript
// app/posthog.js
import { PostHog } from 'posthog-node'
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
})
return posthogClient
}
```
> **Note:** Because server-side functions in Next.js can be short-lived, we set `flushAt` to `1` and `flushInterval` to `0`.
>
> - `flushAt` sets how many capture calls we should flush the queue (in one batch).
> - `flushInterval` sets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to call `await posthog.shutdown()` once done.
To use this client, we import it into our pages and call it with the `PostHogClient` function:
JavaScript
PostHog AI
```javascript
import Link from 'next/link'
import PostHogClient from '../posthog'
export default async function About() {
const posthog = PostHogClient()
const flags = await posthog.getAllFlags(
'user_distinct_id' // replace with a user's distinct ID
);
await posthog.shutdown()
return (
<main>
<h1>About</h1>
<Link href="/">Go home</Link>
{ flags['main-cta'] &&
<Link href="http://posthog.com/">Go to PostHog</Link>
}
</main>
)
}
```
## Pages router
For the pages router, we can use the `getServerSideProps` function to access PostHog on the server-side, send events, evaluate feature flags, and more.
This looks like this:
JavaScript
PostHog AI
```javascript
// pages/posts/[id].js
import { useContext, useEffect, useState } from 'react'
import { getServerSession } from "next-auth/next"
import { PostHog } from 'posthog-node'
export default function Post({ post, flags }) {
const [ctaState, setCtaState] = useState()
useEffect(() => {
if (flags) {
setCtaState(flags['blog-cta'])
}
})
return (
<div>
<h1>{post.title}</h1>
<p>By: {post.author}</p>
<p>{post.content}</p>
{ctaState &&
<p><a href="/">Go to PostHog</a></p>
}
<button onClick={likePost}>Like</button>
</div>
)
}
export async function getServerSideProps(ctx) {
const session = await getServerSession(ctx.req, ctx.res)
let flags = null
if (session) {
const client = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_TOKEN,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
}
)
flags = await client.getAllFlags(session.user.email);
client.capture({
distinctId: session.user.email,
event: 'loaded blog article',
properties: {
$current_url: ctx.req.url,
},
});
await client.shutdown()
}
const { posts } = await import('../../blog.json')
const post = posts.find((post) => post.id.toString() === ctx.params.id)
return {
props: {
post,
flags
},
}
}
```
> **Note**: Make sure to *always* call `await client.shutdown()` after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
### Server-side configuration
Next.js overrides the default `fetch` behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.
You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.
TSX
PostHog AI
```jsx
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
// ... your configuration
fetch_options: {
cache: 'force-cache', // Use Next.js cache
next_options: { // Passed to the `next` option for `fetch`
revalidate: 60, // Cache for 60 seconds
tags: ['posthog'], // Can be used with Next.js `revalidateTag` function
},
}
})
```
## Configuring a reverse proxy to PostHog
To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using [Next.js rewrites](/docs/advanced/proxy/nextjs.md), [Next.js middleware](/docs/advanced/proxy/nextjs-middleware.md), and [Vercel rewrites](/docs/advanced/proxy/vercel.md).
## Further reading
- [How to set up Next.js analytics, feature flags, and more](/tutorials/nextjs-analytics.md)
- [How to set up Next.js pages router analytics, feature flags, and more](/tutorials/nextjs-pages-analytics.md)
- [How to set up Next.js A/B tests](/tutorials/nextjs-ab-tests.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better

View File

@@ -16,3 +16,6 @@ URL="http://localhost:3000"
# Default locale of the apps, can be overridden separately in each app.
DEFAULT_LOCALE="en"
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
CLI_SYNC_SECRET="<your-cli-sync-secret>"

115
.github/workflows/release-cli.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Release CLI binaries
# Fires on any push of a tag shaped like `cli-v1.2.3` (prerelease `-alpha.N` OK).
# Builds self-contained `bun build --compile` binaries for darwin/linux/win
# (x64 + arm64) and attaches them to a GitHub Release. The `install.sh`
# fallback path curls these when Node isn't available.
#
# Publishing to npm is still a manual step (pnpm publish from apps/cli) —
# this workflow only handles binary distribution.
on:
push:
tags:
- "cli-v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to build (e.g. cli-v1.0.0-alpha.28)"
required: true
permissions:
contents: write # to upload release assets
jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { target: darwin-x64, bun_target: bun-darwin-x64, runner: macos-latest, ext: "" }
- { target: darwin-arm64, bun_target: bun-darwin-arm64, runner: macos-latest, ext: "" }
- { target: linux-x64, bun_target: bun-linux-x64, runner: ubuntu-latest, ext: "" }
- { target: linux-arm64, bun_target: bun-linux-arm64, runner: ubuntu-latest, ext: "" }
- { target: windows-x64, bun_target: bun-windows-x64, runner: windows-latest, ext: ".exe" }
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2"
- uses: pnpm/action-setup@v4
- name: Install workspace deps
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Compile binary
working-directory: apps/cli
shell: bash
run: |
mkdir -p dist/bin
VERSION=$(node -p "require('./package.json').version")
bun build --compile --minify \
--target=${{ matrix.bun_target }} \
--define "__CLAUDEMESH_VERSION__=\"$VERSION\"" \
src/entrypoints/cli.ts \
--outfile dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
# Smoke test only on native arch. macos-latest runners are ARM64 (Apple
# Silicon); ubuntu-latest is x64. Cross-compiled binaries can't execute
# on the build host, so skip them.
- name: Smoke test (native only)
if: matrix.target == 'darwin-arm64' || matrix.target == 'linux-x64'
working-directory: apps/cli
run: |
./dist/bin/claudemesh-${{ matrix.target }} --version
./dist/bin/claudemesh-${{ matrix.target }} --help | head -5
- name: Upload artefact
uses: actions/upload-artifact@v4
with:
name: claudemesh-${{ matrix.target }}
path: apps/cli/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage binaries
run: |
mkdir -p release
find artifacts -type f -exec cp {} release/ \;
cd release && sha256sum claudemesh-* > SHA256SUMS
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
release/claudemesh-*
release/SHA256SUMS
generate_release_notes: true
fail_on_unmatched_files: true
update-homebrew:
needs: release
runs-on: macos-latest
if: github.event_name == 'push' && !contains(github.ref_name, 'alpha')
steps:
- name: Bump Homebrew tap formula
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
brew tap alezmad/claudemesh || true
brew bump-formula-pr --no-browse --no-fork \
--tag "${{ github.ref_name }}" \
--revision "${{ github.sha }}" \
alezmad/claudemesh/claudemesh || echo "formula bump skipped (no tap yet)"

View File

@@ -45,3 +45,12 @@ jobs:
- name: 🧪 Test
run: pnpm run test
- name: 📦 Build CLI bundle (check size budget)
working-directory: apps/cli
run: pnpm run build
- name: 🔧 CLI smoke — --version + --help
run: |
node apps/cli/dist/entrypoints/cli.js --version
node apps/cli/dist/entrypoints/cli.js --help | head -5

4
.gitignore vendored
View File

@@ -45,6 +45,9 @@ yarn-error.log*
# local env files
.env*.local
# secrets
.cli_sync_secret
# vercel
.vercel
@@ -72,3 +75,4 @@ dist/
apps/web/payload.db
apps/web/public/media/*
!apps/web/public/media/.gitkeep
.env.local

View File

@@ -1,3 +1,3 @@
{
"geminiApiKey": "AIzaSyBblLRkmypvabqI-xJ_b2KPVA9Pswtav0M"
"geminiApiKey": "AIzaSyDJEyW5Q_OT1X4iGO_5jdVnq1BNANR7s2k"
}

34
CLAUDE.md Normal file
View File

@@ -0,0 +1,34 @@
# claudemesh
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
## Structure
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`. Runs drizzle migrations on startup under pg_advisory_lock.
- `apps/cli/``claudemesh-cli` npm package (CLI + MCP server). Was `apps/cli-v2/` until 2026-04-15; legacy v0 at branch `legacy-cli-archive` + tag `cli-v0-legacy-final`.
- `apps/web/` — Marketing site + dashboard at claudemesh.com
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
- `packaging/` — Homebrew formula + winget manifest templates
- `.github/workflows/release-cli.yml` — tag `cli-v*` → 5 platform binaries → GitHub Release with SHA256SUMS
## Key docs
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
- `docs/protocol.md` — Wire protocol reference
- `docs/roadmap.md` — Public roadmap (shipped + planned)
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
## Deploy
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`. Pending migrations apply automatically on startup.
- **CLI:**
- npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts`
- Binaries: `git tag cli-v<version> && git push github cli-v<version>` — workflow builds 5 platforms.
- **Web:** Vercel auto-deploy on push to GitHub
## Dev
- Monorepo: pnpm workspaces + Turborepo
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
- CLI link for local testing: `cd apps/cli && npm link`

66
SPEC.md
View File

@@ -931,6 +931,72 @@ The session keypair generates once on first connect and survives reconnects. Mes
---
## 14b. Invites (v2 protocol)
### Why v2
The v1 invite token embeds `mesh_root_key` (32-byte shared secret) inside a base64url URL. Any path that caches URLs — link previews, browser history, sync, screenshots, analytics pixels, error logs — is a permanent compromise of the mesh key. Revoking the invite does not rotate the key. The URL *is* the secret.
v2 removes all secret material from the URL. The invite becomes a short opaque code that grants the *right* to receive the key, not the key itself. The server only releases the key after the recipient proves they can receive it, sealed to a public key the recipient controls.
### Canonical bytes
The mesh owner ed25519 secret key signs:
```
v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex
```
No `root_key`, no `broker_url`. The signed capability lives in the broker DB. The user-visible URL is `claudemesh.com/i/{code}` — base62, 8 chars.
### Claim flow
```
1. Admin mints invite
broker stores {id, mesh_id, code, role, max_uses, expires_at,
signed_capability, version=2}
returns claudemesh.com/i/{code}
2. Recipient lands on /i/{code}
web resolves the code, shows consent: mesh name, inviter, role,
expiry, member count. No secrets in the response.
3. Recipient generates a fresh x25519 keypair
(separate from its ed25519 identity — distinct curve, distinct use)
4. Recipient POSTs its x25519 public key
POST /api/public/invites/{code}/claim
body: { recipient_x25519_pubkey }
5. Broker validates and seals
verifies signed_capability against mesh.owner_pubkey
checks expires_at, max_uses vs used_count, revoked_at
creates mesh.member row, increments used_count
sealed_root_key = crypto_box_seal(root_key, recipient_x25519_pubkey)
returns { sealed_root_key, mesh_id, member_id, owner_pubkey,
canonical_v2 }
6. Recipient unseals with its x25519 secret
root_key = crypto_box_seal_open(sealed_root_key, recipient_x25519_sk)
joins normal mesh traffic
```
The server never sees the recipient's private key. `crypto_box_seal` is anonymous — no sender identity, no interaction beyond the single HTTP round trip.
### v1 deprecation timeline
- v0.1.x: the broker, CLI, and web accept both v1 (long token with embedded key) and v2 (short code + sealed key delivery). New invites default to v2.
- v0.2.0: v1 endpoints return `410 Gone`. Existing members already in a mesh are unaffected — the key rotation story is orthogonal to invite format.
### DB additions
- `mesh.invite.version` int default 1
- `mesh.invite.capability_v2` text nullable — the canonical signed bytes
- `mesh.invite.claimed_by_pubkey` text nullable — the recipient x25519 pubkey used at claim time (audit trail, single-use enforcement)
- `mesh.pending_invite` new table for email invites: `{id, meshId, email, code, sentAt, acceptedAt, revokedAt, createdBy, createdAt}`. Email delivery goes through Postmark (already wired via turbostarter).
---
## 14. Production hardening (implemented)
| Feature | Description |

View File

@@ -35,9 +35,18 @@ ENV BROKER_PORT=7900
COPY --from=deps --chown=bun:bun /deploy /app
# Copy migrations folder alongside the broker so runtime auto-migrate
# has files to apply. Workspace deploy subset drops them otherwise.
COPY --from=deps --chown=bun:bun /app/packages/db/migrations /app/migrations
EXPOSE 7900
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
# Liveness (Docker HEALTHCHECK) hits /health — permissive, tolerates
# transient DB blips so the container isn't killed during brief DB
# restarts. Deploy-time readiness is a separate /health/ready endpoint
# which checks DB + migration version; an external gate should poll
# that after container start and fail the deploy if not green.
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
# Non-root user (oven/bun image ships with 'bun' uid 1000)

View File

@@ -15,13 +15,20 @@
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@qdrant/js-client-rest": "1.17.0",
"@react-email/components": "0.3.2",
"@react-email/render": "1.3.2",
"@turbostarter/db": "workspace:*",
"@turbostarter/shared": "workspace:*",
"drizzle-orm": "0.44.7",
"grammy": "^1.35.0",
"libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"postgres": "3.4.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"ws": "8.20.0",
"zod": "catalog:"
},
@@ -31,6 +38,8 @@
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/react": "19.2.0",
"@types/react-dom": "19.2.0",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",

View File

@@ -0,0 +1,101 @@
/**
* One-shot backfill: every active mesh whose owner has no peer-identity
* member row gets one minted via a fresh ed25519 keypair. Without this,
* web-first owners (who never connected via CLI) can't access the chat
* surface — issueDashboardApiKey is a FK to mesh.member, and the topic
* page server component's owner branch picks the oldest member row in
* the mesh (which is null if none exist).
*
* Idempotent. Safe to re-run. Each run prints per-mesh status.
*
* Owner identification: a member is the "owner's row" when its user_id
* matches mesh.owner_user_id. The script targets meshes that have zero
* such matching rows (regardless of total member count — a mesh with
* peers but no owner member also gets a fresh owner row).
*
* The owner row is also auto-subscribed to #general as 'lead' so the
* unread/role accounting matches CLI-flow meshes.
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/backfill-owner-members.ts
*/
import postgres from "postgres";
import sodium from "libsodium-wrappers";
interface Orphan {
meshId: string;
slug: string;
ownerUserId: string;
meshName: string;
}
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
await sodium.ready;
const sql = postgres(url, { max: 1, onnotice: () => {} });
try {
const orphans = await sql<Orphan[]>`
SELECT m.id AS "meshId", m.slug, m.owner_user_id AS "ownerUserId", m.name AS "meshName"
FROM mesh.mesh m
WHERE m.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM mesh.member mm
WHERE mm.mesh_id = m.id
AND mm.revoked_at IS NULL
AND mm.user_id = m.owner_user_id
)
ORDER BY m.created_at
`;
console.log(`backfill · ${orphans.length} meshes need an owner member row`);
let inserted = 0;
for (const o of orphans) {
const kp = sodium.crypto_sign_keypair();
const peerPubkey = sodium.to_hex(kp.publicKey);
const id = sodium.to_hex(sodium.randombytes_buf(16));
try {
await sql.begin(async (tx) => {
await tx`
INSERT INTO mesh.member (
id, mesh_id, peer_pubkey, display_name, role,
user_id, dashboard_user_id
)
VALUES (
${id}, ${o.meshId}, ${peerPubkey},
${o.meshName + "-owner"}, ${"admin"}::mesh.role,
${o.ownerUserId}, ${o.ownerUserId}
)
`;
// Subscribe to #general as 'lead' if the topic exists.
await tx`
INSERT INTO mesh.topic_member (topic_id, member_id, role)
SELECT t.id, ${id}, ${"lead"}::mesh.topic_member_role
FROM mesh.topic t
WHERE t.mesh_id = ${o.meshId} AND t.name = 'general'
ON CONFLICT (topic_id, member_id) DO NOTHING
`;
});
inserted += 1;
console.log(` + ${o.slug.padEnd(20)} owner=${o.ownerUserId.slice(0, 8)}… member=${id.slice(0, 8)}… pk=${peerPubkey.slice(0, 12)}`);
} catch (e) {
console.error(`${o.slug}: ${(e as Error).message}`);
throw e;
}
}
console.log(`backfill done · ${inserted} owner member rows inserted`);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((e) => {
console.error("backfill failed:", e);
process.exit(1);
});

View File

@@ -0,0 +1,87 @@
/**
* One-shot bootstrap for the new mesh.__cmh_migrations tracking table.
*
* Run this against an EXISTING prod DB exactly once before deploying
* the new runtime migrator. It:
* 1. Creates mesh.__cmh_migrations if it doesn't exist
* 2. Hashes every .sql file in packages/db/migrations
* 3. Inserts a row per file (filename + sha256) with applied_at = NOW()
* 4. ON CONFLICT (filename) DO NOTHING — safe to re-run
*
* The script does NOT execute any migration SQL — it only seeds the
* tracking table to reflect the schema state that was previously
* applied by drizzle (or by hand). After this runs, the broker's
* startup migrator will treat 0000..N as already-applied and only
* apply truly new files going forward.
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/bootstrap-cmh-migrations.ts
*
* Safe to run multiple times. Output prints per-file status.
*/
import postgres from "postgres";
import { join } from "node:path";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
const candidates = [
join(process.cwd(), "..", "..", "packages", "db", "migrations"),
join(process.cwd(), "packages", "db", "migrations"),
"/app/migrations",
];
const folder = candidates.find((p) => existsSync(p));
if (!folder) {
console.error("migrations folder not found");
process.exit(2);
}
const files = readdirSync(folder).filter((f) => f.endsWith(".sql")).sort();
console.log(`bootstrap · ${files.length} files at ${folder}`);
const sql = postgres(url, { max: 1, onnotice: () => {} });
try {
await sql.unsafe(`
CREATE SCHEMA IF NOT EXISTS mesh;
CREATE TABLE IF NOT EXISTS mesh.__cmh_migrations (
filename TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
let inserted = 0;
let skipped = 0;
for (const f of files) {
const content = readFileSync(join(folder, f), "utf8");
const sha = createHash("sha256").update(content).digest("hex");
const result = await sql`
INSERT INTO mesh.__cmh_migrations (filename, sha256)
VALUES (${f}, ${sha})
ON CONFLICT (filename) DO NOTHING
RETURNING filename
`;
if (result.length > 0) {
inserted += 1;
console.log(` + ${f} ${sha.slice(0, 12)}`);
} else {
skipped += 1;
}
}
console.log(`bootstrap done · ${inserted} inserted, ${skipped} already tracked`);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((e) => {
console.error("bootstrap failed:", e);
process.exit(1);
});

263
apps/broker/src/audit.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* Signed audit log with hash-chain integrity.
*
* Every significant mesh event is recorded as an append-only entry.
* Each entry's SHA-256 hash includes the previous entry's hash,
* forming a tamper-evident chain per mesh. If any row is modified
* or deleted, all subsequent hashes will fail verification.
*
* NEVER logs message content (ciphertext or plaintext) — only metadata.
*/
import { createHash } from "node:crypto";
import { asc, desc, eq, sql, and } from "drizzle-orm";
import { db } from "./db";
import { auditLog } from "@turbostarter/db/schema/mesh";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
// ---------------------------------------------------------------------------
const lastHash = new Map<string, string>();
// ---------------------------------------------------------------------------
// Core audit logging
// ---------------------------------------------------------------------------
/**
* Deterministic JSON serialization: keys sorted recursively. The store
* is JSONB, which does NOT preserve key order, so hashing a naive
* JSON.stringify(row.payload) on verify can yield a different string
* from insert-time — false tamper reports. Canonical form guarantees
* both sides agree.
*/
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return "[" + value.map(canonicalJson).join(",") + "]";
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return (
"{" +
keys
.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k]))
.join(",") +
"}"
);
}
function computeHash(
prevHash: string,
meshId: string,
eventType: string,
actorMemberId: string | null,
payload: Record<string, unknown>,
createdAt: Date,
): string {
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${canonicalJson(payload)}|${createdAt.toISOString()}`;
return createHash("sha256").update(input).digest("hex");
}
/**
* Stable 63-bit lock key per mesh for audit serialization under HA.
* Use the audit lock space; keep distinct from migrate's 74737_73831.
*/
function meshLockKey(meshId: string): bigint {
const digest = createHash("sha256").update("audit:" + meshId).digest();
const unsigned = digest.readBigUInt64BE(0);
return unsigned & 0x7fffffffffffffffn;
}
/**
* Append an audit entry for a mesh event.
*
* Fire-and-forget safe — callers should `void audit(...)` or
* `.catch(log.warn)` to avoid blocking the hot path.
*
* Concurrency under HA: wraps the write in a transaction that takes
* `pg_advisory_xact_lock(meshLockKey(meshId))` before reading the
* tail hash from the DB. This serializes all concurrent writers to
* the same mesh and prevents the chain from forking. The in-memory
* `lastHash` cache is updated after a successful commit.
*/
export async function audit(
meshId: string,
eventType: string,
actorMemberId: string | null,
actorDisplayName: string | null,
payload: Record<string, unknown>,
): Promise<void> {
const createdAt = new Date();
try {
await db.transaction(async (tx) => {
const key = meshLockKey(meshId);
await tx.execute(sql`SELECT pg_advisory_xact_lock(${key}::bigint)`);
const [latest] = await tx
.select({ hash: auditLog.hash })
.from(auditLog)
.where(eq(auditLog.meshId, meshId))
.orderBy(desc(auditLog.id))
.limit(1);
const prevHash = latest?.hash ?? "genesis";
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
await tx.insert(auditLog).values({
meshId,
eventType,
actorMemberId,
actorDisplayName,
payload,
prevHash,
hash,
createdAt,
});
lastHash.set(meshId, hash);
});
} catch (e) {
log.warn("audit log insert failed", {
mesh_id: meshId,
event_type: eventType,
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Startup: load last hash per mesh from DB
// ---------------------------------------------------------------------------
export async function loadLastHashes(): Promise<void> {
try {
// For each mesh, find the most recent audit entry by id (serial).
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
SELECT DISTINCT ON (mesh_id) mesh_id, hash
FROM mesh.audit_log
ORDER BY mesh_id, id DESC
`);
for (const row of rows) {
lastHash.set(row.mesh_id, row.hash);
}
log.info("audit: loaded last hashes", { meshes: lastHash.size });
} catch (e) {
// Table may not exist yet on first boot — that's fine.
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Chain verification
// ---------------------------------------------------------------------------
export async function verifyChain(
meshId: string,
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
const rows = await db
.select()
.from(auditLog)
.where(eq(auditLog.meshId, meshId))
.orderBy(asc(auditLog.id));
if (rows.length === 0) {
return { valid: true, entries: 0 };
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
// Verify prevHash linkage
if (row.prevHash !== expectedPrevHash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
// Recompute hash and verify
const recomputed = computeHash(
row.prevHash,
row.meshId,
row.eventType,
row.actorMemberId,
row.payload as Record<string, unknown>,
row.createdAt,
);
if (recomputed !== row.hash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
}
return { valid: true, entries: rows.length };
}
// ---------------------------------------------------------------------------
// Query: paginated audit entries
// ---------------------------------------------------------------------------
export async function queryAuditLog(
meshId: string,
options?: { limit?: number; offset?: number; eventType?: string },
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
const conditions = [eq(auditLog.meshId, meshId)];
if (options?.eventType) {
conditions.push(eq(auditLog.eventType, options.eventType));
}
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
const [rows, countResult] = await Promise.all([
db
.select()
.from(auditLog)
.where(where)
.orderBy(desc(auditLog.id))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLog)
.where(where),
]);
return {
entries: rows.map((r) => ({
id: r.id,
eventType: r.eventType,
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
payload: r.payload as Record<string, unknown>,
hash: r.hash,
createdAt: r.createdAt.toISOString(),
})),
total: Number(countResult[0]?.count ?? 0),
};
}
// ---------------------------------------------------------------------------
// Ensure table exists (raw DDL for first-boot before migrations run)
// ---------------------------------------------------------------------------
export async function ensureAuditLogTable(): Promise<void> {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS mesh.audit_log (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
event_type TEXT NOT NULL,
actor_member_id TEXT,
actor_display_name TEXT,
payload JSONB NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
} catch (e) {
log.warn("audit: ensureAuditLogTable failed", {
error: e instanceof Error ? e.message : String(e),
});
}
}

View File

@@ -0,0 +1,82 @@
/**
* Broker-side symmetric encryption for persisting resolved env vars.
*
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
* a random key is generated and logged on first use — operator should
* persist it to survive broker restarts.
*
* This is NOT the same as peer-side E2E crypto (libsodium). This is
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { env } from "./env";
import { log } from "./logger";
const ALGO = "aes-256-gcm";
const IV_LEN = 12;
const TAG_LEN = 16;
let _key: Buffer | null = null;
function getKey(): Buffer {
if (_key) return _key;
if (env.BROKER_ENCRYPTION_KEY && /^[0-9a-f]{64}$/i.test(env.BROKER_ENCRYPTION_KEY)) {
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
return _key;
}
// In production, refuse to start without a persistent key. Silently
// generating a random one meant every restart invalidated all encrypted
// rows on disk — and the ephemeral key was logged in clear, which is
// itself a leak.
if (process.env.NODE_ENV === "production") {
log.error("BROKER_ENCRYPTION_KEY is missing or malformed (need 64 hex chars) — refusing to start in production");
process.exit(1);
}
// Dev only: generate a stable per-process key. Never log the value.
_key = randomBytes(32);
log.warn("BROKER_ENCRYPTION_KEY not set — using ephemeral key for this dev process (encrypted data WILL NOT survive restarts). Set BROKER_ENCRYPTION_KEY to a 64-hex-char value for persistence.");
return _key;
}
/**
* Encrypt a JSON-serializable value. Returns a base64 string containing
* IV + ciphertext + auth tag.
*/
export function encryptForStorage(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Pack: IV (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
/**
* Decrypt a value produced by encryptForStorage. Returns the plaintext
* string, or null if decryption fails (wrong key, tampered).
*/
export function decryptFromStorage(packed: string): string | null {
try {
const key = getKey();
const buf = Buffer.from(packed, "base64");
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
} catch (e) {
// Loud failure: if a stored row fails to decrypt the key changed or
// data is corrupt — don't silently return null and let downstream
// code assume "no value".
log.error("decryptFromStorage failed", { err: e instanceof Error ? e.message : String(e) });
return null;
}
}

File diff suppressed because it is too large Load Diff

133
apps/broker/src/cli-sync.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* POST /cli-sync handler.
*
* Accepts a sync JWT from the dashboard, creates or finds member rows
* for each mesh in the token, and returns mesh details + member IDs.
*/
import { and, eq, isNull } from "drizzle-orm";
import { db } from "./db";
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
// Import schema tables
import {
mesh as meshTable,
meshMember as memberTable,
} from "@turbostarter/db/schema/mesh";
import { generateId } from "@turbostarter/shared/utils";
export interface CliSyncRequest {
sync_token: string;
peer_pubkey: string; // ed25519 hex (64 chars)
display_name: string;
}
export interface CliSyncResponse {
ok: true;
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
export interface CliSyncError {
ok: false;
error: string;
}
export async function handleCliSync(
body: CliSyncRequest,
): Promise<CliSyncResponse | CliSyncError> {
// 1. Validate inputs
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
}
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
}
// 2. Verify JWT
const tokenResult = await verifySyncToken(body.sync_token);
if (!tokenResult.ok) {
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
}
const payload = tokenResult.payload;
// 3. For each mesh in the token, create or find a member row
const resultMeshes: CliSyncResponse["meshes"] = [];
for (const tokenMesh of payload.meshes) {
// Verify mesh exists and is not archived
const [m] = await db
.select({ id: meshTable.id, slug: meshTable.slug })
.from(meshTable)
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
if (!m) {
// Skip meshes that don't exist (could have been deleted)
continue;
}
// Check if this pubkey is already a member of this mesh
const [existing] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.meshId, tokenMesh.id),
eq(memberTable.peerPubkey, body.peer_pubkey),
isNull(memberTable.revokedAt),
),
);
let memberId: string;
let role: "admin" | "member";
if (existing) {
// Already a member — update dashboard link + display name
memberId = existing.id;
role = existing.role;
await db
.update(memberTable)
.set({
dashboardUserId: payload.sub,
displayName: body.display_name,
})
.where(eq(memberTable.id, existing.id));
} else {
// Create new member row
memberId = generateId();
role = tokenMesh.role;
await db.insert(memberTable).values({
id: memberId,
meshId: tokenMesh.id,
peerPubkey: body.peer_pubkey,
displayName: body.display_name,
role: tokenMesh.role,
dashboardUserId: payload.sub,
});
}
resultMeshes.push({
mesh_id: tokenMesh.id,
slug: m.slug,
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
member_id: memberId,
role,
});
}
if (resultMeshes.length === 0) {
return { ok: false, error: "no valid meshes found in sync token" };
}
return {
ok: true,
account_id: payload.sub,
meshes: resultMeshes,
};
}

View File

@@ -7,7 +7,10 @@
* current member of the claimed mesh.
*/
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "./db";
import { invite as inviteTable, mesh, meshMember, meshTopic, meshTopicMember } from "@turbostarter/db/schema/mesh";
let ready = false;
async function ensureSodium(): Promise<typeof sodium> {
@@ -69,6 +72,70 @@ export async function verifyEd25519(
}
}
/**
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
* protocol moves the root_key out of the URL entirely. Format is locked:
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
*/
export function canonicalInviteV2(p: {
mesh_id: string;
invite_id: string;
expires_at: number; // unix seconds
role: "admin" | "member";
owner_pubkey: string; // hex
}): string {
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
}
/**
* Verify an ed25519 signature over the v2 canonical invite bytes against
* the mesh owner's public key. Returns true on valid signature.
*/
export async function verifyInviteV2(params: {
canonical: string;
signatureHex: string;
ownerPubkeyHex: string;
}): Promise<boolean> {
return verifyEd25519(
params.canonical,
params.signatureHex,
params.ownerPubkeyHex,
);
}
/**
* Seal the mesh root_key to a recipient-provided x25519 public key using
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
* x25519 secret key can unseal.
*
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
*
* Returns base64url of the sealed ciphertext.
*/
export async function sealRootKeyToRecipient(params: {
rootKeyBase64url: string;
recipientX25519PubkeyBase64url: string;
}): Promise<string> {
const s = await ensureSodium();
const rootKeyBytes = s.from_base64(
params.rootKeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
const recipientPk = s.from_base64(
params.recipientX25519PubkeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
if (recipientPk.length !== 32) {
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
}
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
}
export const HELLO_SKEW_MS = 60_000;
/**
@@ -118,3 +185,211 @@ export async function verifyHelloSignature(args: {
return { ok: false, reason: "malformed" };
}
}
// ----------------------------------------------------------------------------
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
// tests that need to exercise the logic without spinning up the broker server.
// ----------------------------------------------------------------------------
//
// capabilityV2 column is stored as JSON:
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
// "signature": "<hex ed25519 detached signature>" }
// The broker recomputes the canonical bytes from the invite row and verifies
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
// capabilityV2 === null) skip verification — the legacy path still works
// during the deprecation window.
export type InviteClaimV2Result =
| {
ok: true;
status: 200;
body: {
sealed_root_key: string;
mesh_id: string;
member_id: string;
owner_pubkey: string;
canonical_v2: string;
};
}
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
export async function claimInviteV2Core(params: {
code: string;
recipientX25519PubkeyBase64url: string;
displayName?: string;
now?: number;
}): Promise<InviteClaimV2Result> {
const now = params.now ?? Date.now();
const recipientPk = params.recipientX25519PubkeyBase64url;
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 1. Look up the invite by opaque code.
const [inv] = await db
.select()
.from(inviteTable)
.where(eq(inviteTable.code, params.code))
.limit(1);
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
// 2. Lifecycle checks: revoked → expired → exhausted.
if (inv.revokedAt) {
return { ok: false, status: 410, body: { error: "revoked" } };
}
if (inv.expiresAt.getTime() < now) {
return { ok: false, status: 410, body: { error: "expired" } };
}
if (inv.usedCount >= inv.maxUses) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 3. Load the mesh for owner_pubkey + root_key.
const [m] = await db
.select({
id: mesh.id,
ownerPubkey: mesh.ownerPubkey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
.limit(1);
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
if (!m.ownerPubkey || !m.rootKey) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 4. Compute canonical_v2 from the row (used in the response either way).
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: inv.meshId,
invite_id: inv.id,
expires_at: expiresAtUnix,
role: inv.role as "admin" | "member",
owner_pubkey: m.ownerPubkey,
});
if (inv.version === 2 && inv.capabilityV2) {
let storedCanonical: string | undefined;
let signatureHex: string | undefined;
try {
const parsed = JSON.parse(inv.capabilityV2) as {
canonical?: string;
signature?: string;
};
storedCanonical = parsed.canonical;
signatureHex = parsed.signature;
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
if (!storedCanonical || !signatureHex) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// Broker-recomputed canonical must match the signed bytes exactly.
if (storedCanonical !== canonical) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
const sigOk = await verifyInviteV2({
canonical: storedCanonical,
signatureHex,
ownerPubkeyHex: m.ownerPubkey,
});
if (!sigOk) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
}
// v1 rows: skip signature verification (legacy path during migration).
// 5. Atomic consume: increment used_count iff still under max_uses.
const [claimed] = await db
.update(inviteTable)
.set({
usedCount: sql`${inviteTable.usedCount} + 1`,
claimedByPubkey: recipientPk,
})
.where(
and(
eq(inviteTable.id, inv.id),
lt(inviteTable.usedCount, inv.maxUses),
),
)
.returning({ id: inviteTable.id });
if (!claimed) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 6. Create a member row for the claimant.
const preset = (inv.preset as {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: string;
} | null) ?? {};
const displayName =
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
const [row] = await db
.insert(meshMember)
.values({
meshId: inv.meshId,
peerPubkey: recipientPk,
displayName,
role: inv.role,
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
})
.returning({ id: meshMember.id });
if (!row) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 6b. Auto-subscribe the new member to #general (the default mesh-wide
// room). Idempotent via unique (topic_id, member_id). If the mesh was
// created before #general auto-creation existed, ensure it now via a
// best-effort INSERT … ON CONFLICT — backfill migration handles the
// bulk case so this is just a safety net.
await db
.insert(meshTopic)
.values({
meshId: inv.meshId,
name: "general",
description: "Default mesh-wide channel. Every member can read and post.",
visibility: "public",
})
.onConflictDoNothing();
const [generalTopic] = await db
.select({ id: meshTopic.id })
.from(meshTopic)
.where(and(eq(meshTopic.meshId, inv.meshId), eq(meshTopic.name, "general")))
.limit(1);
if (generalTopic) {
await db
.insert(meshTopicMember)
.values({ topicId: generalTopic.id, memberId: row.id, role: "member" })
.onConflictDoNothing();
}
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
let sealed: string;
try {
sealed = await sealRootKeyToRecipient({
rootKeyBase64url: m.rootKey,
recipientX25519PubkeyBase64url: recipientPk,
});
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
return {
ok: true,
status: 200,
body: {
sealed_root_key: sealed,
mesh_id: inv.meshId,
member_id: row.id,
owner_pubkey: m.ownerPubkey,
canonical_v2: canonical,
},
};
}

View File

@@ -0,0 +1,320 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface MeshInvitationProps {
meshName: string;
inviteUrl: string;
token: string;
expiresAt: string;
appBaseUrl: string;
}
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
// Inlined here because email clients don't resolve CSS vars.
const brand = {
bg: "#141413",
bgElevated: "#1f1e1d",
bgCode: "#0f0e0d",
fg: "#faf9f5",
fgSecondary: "#c2c0b6",
fgTertiary: "#87867f",
clay: "#d97757",
clayBorder: "rgba(217, 119, 87, 0.35)",
border: "rgba(217, 119, 87, 0.2)",
serif: 'Georgia, "Times New Roman", serif',
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
sans:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
} as const;
export const MeshInvitation = ({
meshName,
inviteUrl,
token,
expiresAt,
appBaseUrl,
}: MeshInvitationProps) => {
const expiresLabel = new Date(expiresAt).toUTCString();
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
return (
<Html lang="en">
<Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
<Body
style={{
backgroundColor: brand.bg,
color: brand.fg,
fontFamily: brand.sans,
margin: 0,
padding: "40px 0",
}}
>
<Container
style={{
maxWidth: "560px",
margin: "0 auto",
padding: "0 24px",
}}
>
{/* Header — mesh glyph + wordmark */}
<Section style={{ marginBottom: "40px" }}>
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
<tr>
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="4" r="2" fill={brand.clay} />
<circle cx="4" cy="12" r="2" fill={brand.clay} />
<circle cx="20" cy="12" r="2" fill={brand.clay} />
<circle cx="12" cy="20" r="2" fill={brand.clay} />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke={brand.clay}
strokeWidth="1.2"
opacity="0.45"
fill="none"
/>
</svg>
</td>
<td style={{ verticalAlign: "middle" }}>
<Text
style={{
fontFamily: brand.serif,
fontSize: "17px",
fontWeight: 500,
letterSpacing: "-0.01em",
color: brand.fg,
margin: 0,
}}
>
claudemesh
</Text>
</td>
</tr>
</table>
</Section>
{/* Eyebrow */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.clay,
margin: "0 0 16px 0",
}}
>
— you're invited
</Text>
{/* Heading */}
<Heading
as="h1"
style={{
fontFamily: brand.serif,
fontSize: "32px",
fontWeight: 500,
lineHeight: "1.15",
letterSpacing: "-0.01em",
color: brand.fg,
margin: "0 0 20px 0",
}}
>
Join{" "}
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
{meshName}
</span>{" "}
on claudemesh
</Heading>
{/* Body prose */}
<Text
style={{
fontFamily: brand.serif,
fontSize: "16px",
lineHeight: "1.65",
color: brand.fgSecondary,
margin: "0 0 32px 0",
}}
>
claudemesh is a peer mesh for Claude Code sessions end-to-end
encrypted, keys stay on your machine. Open the link below to see
the mesh, the inviter, and the command to join.
</Text>
{/* Primary CTA */}
<Section style={{ marginBottom: "36px" }}>
<Button
href={inviteUrl}
style={{
backgroundColor: brand.clay,
color: brand.fg,
fontFamily: brand.sans,
fontSize: "15px",
fontWeight: 500,
textDecoration: "none",
padding: "14px 28px",
borderRadius: "4px",
display: "inline-block",
}}
>
Open invite
</Button>
</Section>
{/* Terminal shortcut — for the already-set-up crowd */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.fgTertiary,
margin: "0 0 12px 0",
}}
>
already have the CLI?
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.clayBorder}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
wordBreak: "break-all",
lineHeight: "1.6",
}}
>
{launchCmd}
</Text>
</Section>
{/* First-time one-liner */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.fgTertiary,
margin: "0 0 12px 0",
}}
>
first time? one command
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.border}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
lineHeight: "1.6",
wordBreak: "break-all",
}}
>
{oneLiner}
</Text>
<Text
style={{
fontFamily: brand.serif,
fontSize: "12px",
color: brand.fgTertiary,
margin: "8px 0 0 0",
}}
>
Requires Node.js 20+. Display name defaults to $USER.
</Text>
</Section>
<Hr
style={{
border: "none",
borderTop: `1px solid ${brand.border}`,
margin: "28px 0 20px 0",
}}
/>
{/* Footer meta */}
<Text
style={{
fontFamily: brand.serif,
fontSize: "13px",
lineHeight: "1.6",
color: brand.fgTertiary,
margin: "0 0 8px 0",
}}
>
Expires{" "}
<span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
If you weren't expecting this, you can ignore it.
</Text>
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
color: brand.fgTertiary,
margin: 0,
}}
>
<Link
href={appBaseUrl}
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
>
claudemesh.com
</Link>
</Text>
</Container>
</Body>
</Html>
);
};
MeshInvitation.PreviewProps = {
meshName: "prueba1",
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
expiresAt: "2026-04-22T00:51:26.181Z",
appBaseUrl: "https://claudemesh.com",
} satisfies MeshInvitationProps;
export default MeshInvitation;

View File

@@ -23,11 +23,18 @@ const envSchema = z.object({
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.coerce.boolean().default(false),
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"),
NEO4J_PASSWORD: z.string().default("changeme"),
RUNNER_URL: z.string().default("http://runner:7901"),
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
ANTHROPIC_API_KEY: z.string().default(""), // Claude API key for Telegram AI bot
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),

File diff suppressed because it is too large Load Diff

146
apps/broker/src/jwt.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* JWT verification for CLI sync tokens.
*
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
* shared secret between dashboard and broker via env var.
*
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
*/
import { env } from "./env";
// --- Types ---
export interface SyncTokenPayload {
sub: string; // dashboard user ID
email: string;
meshes: Array<{
id: string;
slug: string;
role: "admin" | "member";
}>;
action: "sync" | "create";
newMesh?: {
name: string;
slug: string;
};
jti: string; // unique token ID for replay prevention
iat: number;
exp: number;
}
// --- JTI dedup ---
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
// Sweep expired JTIs every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [jti, exp] of usedJtis) {
if (exp < now) usedJtis.delete(jti);
}
}, 5 * 60_000);
// --- Verification ---
/**
* Verify and decode a sync token JWT.
* Returns the decoded payload on success, or an error string on failure.
*/
export async function verifySyncToken(
token: string,
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
// Get shared secret from env
const secret = env.CLI_SYNC_SECRET;
if (!secret) {
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
}
try {
// Decode JWT manually (HS256)
const parts = token.split(".");
if (parts.length !== 3) {
return { ok: false, error: "malformed JWT" };
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Verify signature (HS256)
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
if (!valid) {
return { ok: false, error: "invalid signature" };
}
// Decode header — must be HS256
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
if (header.alg !== "HS256") {
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
}
// Decode payload
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
) as SyncTokenPayload;
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
return { ok: false, error: "token expired" };
}
// Check iat not in the future (30s tolerance)
if (payload.iat && payload.iat > now + 30) {
return { ok: false, error: "token issued in the future" };
}
// JTI dedup
if (!payload.jti) {
return { ok: false, error: "missing jti" };
}
if (usedJtis.has(payload.jti)) {
return { ok: false, error: "token already used" };
}
// Mark as used with expiry time
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
// Basic validation
if (!payload.sub || !payload.email) {
return { ok: false, error: "missing sub or email" };
}
if (!Array.isArray(payload.meshes)) {
return { ok: false, error: "missing meshes array" };
}
return { ok: true, payload };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// --- Helpers ---
function base64UrlDecode(input: string): Uint8Array {
// Add padding
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) base64 += "=";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,284 @@
/**
* Member profile REST API handlers.
*
* PATCH /mesh/:meshId/member/:memberId — update member profile
* GET /mesh/:meshId/members — list all members with online status
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
*
* These are standalone handler functions. Route wiring happens in index.ts.
*/
import { and, eq, isNull, sql } from "drizzle-orm";
import { db } from "./db";
import {
mesh as meshTable,
meshMember as memberTable,
presence as presenceTable,
} from "@turbostarter/db/schema/mesh";
// --- Types ---
export interface MemberProfileUpdate {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: "push" | "inbox" | "off";
}
export interface MemberPermissionUpdate {
permission?: "admin" | "member"; // only admins can change this
}
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
interface SelfEditablePolicy {
displayName: boolean;
roleTag: boolean;
groups: boolean;
messageMode: boolean;
}
// --- Handlers ---
/**
* Update a member's profile fields.
*
* Authorization:
* - If caller is the target member: check mesh.selfEditable for each field
* - If caller is a mesh admin: allow all fields
* - permission field: admin-only always
*
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
*/
export async function updateMemberProfile(
meshId: string,
memberId: string,
callerMemberId: string, // from auth header or WS connection
updates: MemberUpdateRequest,
): Promise<
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
| { ok: false; error: string }
> {
// 1. Load mesh for selfEditable policy
const [m] = await db
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// 2. Load caller's member row to check permission
const [caller] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
const isAdmin = caller.role === "admin";
const isSelf = callerMemberId === memberId;
if (!isAdmin && !isSelf) {
return {
ok: false,
error: "not authorized — only admins or self can edit",
};
}
// 3. Check self-edit permissions for non-admin self-edits
const policy: SelfEditablePolicy =
(m.selfEditable as SelfEditablePolicy) ?? {
displayName: true,
roleTag: true,
groups: true,
messageMode: true,
};
const rejected: string[] = [];
if (!isAdmin && isSelf) {
if (updates.displayName !== undefined && !policy.displayName)
rejected.push("displayName");
if (updates.roleTag !== undefined && !policy.roleTag)
rejected.push("roleTag");
if (updates.groups !== undefined && !policy.groups)
rejected.push("groups");
if (updates.messageMode !== undefined && !policy.messageMode)
rejected.push("messageMode");
if (updates.permission !== undefined) rejected.push("permission");
}
if (rejected.length > 0) {
return {
ok: false,
error: `admin-managed fields: ${rejected.join(", ")}`,
};
}
// 4. Build update set
const set: Record<string, unknown> = {};
const changes: MemberProfileUpdate = {};
if (updates.displayName !== undefined) {
set.displayName = updates.displayName;
changes.displayName = updates.displayName;
}
if (updates.roleTag !== undefined) {
set.roleTag = updates.roleTag;
changes.roleTag = updates.roleTag;
}
if (updates.groups !== undefined) {
set.defaultGroups = updates.groups;
changes.groups = updates.groups;
}
if (updates.messageMode !== undefined) {
set.messageMode = updates.messageMode;
changes.messageMode = updates.messageMode;
}
if (updates.permission !== undefined && isAdmin) {
set.role = updates.permission;
}
if (Object.keys(set).length === 0) {
return { ok: false, error: "no fields to update" };
}
// 5. Update member row
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
// 6. Read back the updated member
const [updated] = await db
.select()
.from(memberTable)
.where(eq(memberTable.id, memberId));
if (!updated) return { ok: false, error: "member not found after update" };
return {
ok: true,
member: {
id: updated.id,
displayName: updated.displayName,
roleTag: updated.roleTag,
groups: updated.defaultGroups,
messageMode: updated.messageMode,
permission: updated.role,
dashboardUserId: updated.dashboardUserId,
joinedAt: updated.joinedAt,
lastSeenAt: updated.lastSeenAt,
},
changes,
};
}
/**
* List all members of a mesh with online status.
*/
export async function listMeshMembers(
meshId: string,
): Promise<
| { ok: true; members: Array<Record<string, unknown>> }
| { ok: false; error: string }
> {
// Verify mesh exists
const [m] = await db
.select({ id: meshTable.id })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// Get all non-revoked members
const members = await db
.select()
.from(memberTable)
.where(
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
);
// Early return for empty member list (avoids invalid SQL IN clause)
if (members.length === 0) {
return { ok: true, members: [] };
}
// Get active presences for online status
const activePresences = await db
.select({
memberId: presenceTable.memberId,
count: sql<number>`count(*)::int`,
})
.from(presenceTable)
.where(
and(
isNull(presenceTable.disconnectedAt),
sql`${presenceTable.memberId} IN (${sql.join(
members.map((m) => sql`${m.id}`),
sql`, `,
)})`,
),
)
.groupBy(presenceTable.memberId);
const onlineMap = new Map(
activePresences.map((p) => [p.memberId, p.count]),
);
return {
ok: true,
members: members.map((member) => ({
id: member.id,
displayName: member.displayName,
roleTag: member.roleTag,
groups: member.defaultGroups,
messageMode: member.messageMode,
permission: member.role,
dashboardUserId: member.dashboardUserId,
joinedAt: member.joinedAt?.toISOString(),
lastSeenAt: member.lastSeenAt?.toISOString(),
online: onlineMap.has(member.id),
sessionCount: onlineMap.get(member.id) ?? 0,
})),
};
}
/**
* Update mesh settings (currently: selfEditable policy).
* Admin-only.
*/
export async function updateMeshSettings(
meshId: string,
callerMemberId: string,
settings: { selfEditable?: SelfEditablePolicy },
): Promise<{ ok: true } | { ok: false; error: string }> {
// Check caller is admin
const [caller] = await db
.select({ role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller || caller.role !== "admin") {
return { ok: false, error: "admin access required" };
}
const set: Record<string, unknown> = {};
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
if (Object.keys(set).length === 0) {
return { ok: false, error: "no settings to update" };
}
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
return { ok: true };
}

View File

@@ -90,6 +90,14 @@ export const metrics = {
"broker_messages_rejected_total",
"Messages rejected (size, auth, malformed)",
),
messagesDroppedByGrantTotal: new Counter(
"broker_messages_dropped_by_grant_total",
"Messages silently dropped because recipient didn't grant sender the required capability",
),
brokerLegacyAuthHitsTotal: new Counter(
"broker_legacy_auth_hits_total",
"Pre-alpha.36 clients authenticating via body.user_id fallback (remove shim when near zero)",
),
queueDepth: new Gauge(
"broker_queue_depth",
"Undelivered messages currently in the queue",

Some files were not shown because too many files have changed in this diff Show More