Files
claudemesh/apps/cli/CHANGELOG.md
Alejandro Gutiérrez a2a53ff355 feat(cli,broker): 1.34.14 + 1.34.15 — env-var fallback, peer list scope, kick refuses control-plane
Three follow-ups from the 1.34.x multi-session correctness train,
all backwards-compatible.

1.34.14 — stale CLAUDEMESH_CONFIG_DIR falls back. The launch flow
exposes CLAUDEMESH_CONFIG_DIR=<tmpdir> to its spawned claude; if a
later claudemesh invocation inherited that env (Bash tool inside
Claude Code, tmux update-environment, exported var), the inherited
path pointed at a tmpdir that no longer existed and readConfig()
silently returned empty. paths.ts now memoizes resolution: env unset
→ default; env points at a real dir → trust it; env set but dir gone
→ TTY-only stderr warning with shell-specific unset hint, fall back
to ~/.claudemesh.

1.34.15 — peer list --mesh actually scopes. peers.ts and launch.ts
were calling tryListPeersViaDaemon() with no argument; the daemon's
?mesh= filter (server-side, since 1.26.0) was already correct, the
CLI just wasn't passing the slug. Forwarding fixed in both sites;
send.ts cross-mesh hex-prefix resolution intentionally untouched.

1.34.15 — kick refuses no-op kicks on control-plane. Pre-1.34.15
kicking a daemon's member-WS just closed the socket and triggered
auto-reconnect — a no-op with a misleading "session ended" message.
Broker now skips peers where peerRole === "control-plane" and
surfaces them in a new additive ack field skipped_control_plane;
the CLI reads it and prints a clearer hint pointing at ban / daemon
down. Soft disconnect verb keeps old behavior. PeerConn gains a
peerRole slot populated at both connections.set sites.

Tests: 4 new for paths-stale-env, 5 for kick-control-plane-skip.
CLI 87/87 green; broker 55/55 unit green (integration tests
pre-existing infra failure on this machine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:59:06 +01:00

87 KiB
Raw Blame History

Changelog

1.34.15 (2026-05-04) — peer list --mesh actually scopes + kick refuses control-plane

Two follow-ups from the 1.34.x train, both backwards-compatible.

peer list --mesh <slug> no longer aggregates across meshes

apps/cli/src/commands/peers.ts:140 was calling tryListPeersViaDaemon() with no argument, so a multi-mesh daemon returned peers from EVERY attached mesh and the renderer printed "peers on flexicar" with cross-mesh rows mixed in. The daemon's /v1/peers?mesh=<slug> filter (server-side, since 1.26.0) was already correctly scoping when the slug was passed; the CLI just wasn't passing it. Fixed.

apps/cli/src/commands/launch.ts:407 (the printBrokerWelcome peer count in the launch banner) had the same bug. The "N peers online" line in the welcome now shows the count for the launched mesh only.

apps/cli/src/commands/send.ts cross-mesh hex-prefix resolution is intentionally cross-mesh (the user is targeting by hex without specifying a mesh) and was deliberately left as-is.

claudemesh kick refuses no-op kicks on control-plane connections

Pre-1.34.15, kicking a daemon's member-WS or a dashboard connection just closed the socket — the daemon's WS-lifecycle reconnect loop brought it back within seconds, the kicker's CLI rendered "Their Claude Code session ended" (which was misleading), and the user- visible state was unchanged. The verb was effectively a no-op, but the user had to learn that the hard way.

The broker's kick handler (apps/broker/src/index.ts:4628+) now skips peers where peerRole === "control-plane" and surfaces the skipped peers in a new additive ack field skipped_control_plane. The soft disconnect verb keeps the old behavior — useful when intentionally nudging a control-plane peer to re-authenticate.

The CLI (apps/cli/src/commands/kick.ts) reads the new field and prints a clearer message: refused peers are listed, with the hint that claudemesh ban <peer> is the right tool to remove a member, or claudemesh daemon down to take a daemon offline locally.

apps/broker/src/index.ts adds peerRole to the in-memory PeerConn shape, populated from both connection paths (member-keyed hello"control-plane", per-launch session_hello"session"). The DB-side role taxonomy is unchanged.

Back-compat

  • Older CLI clients ignore the new skipped_control_plane ack field; their kick continues to print "Kicked 0 peer(s)" against a control-plane target as before.
  • Older brokers don't emit the field at all; newer CLI handles the absence (the new branch is only reached when the field is present and non-empty).
  • The new peerRole slot on PeerConn is filled at every connections.set callsite, so older code paths never read undefined.

Tests

  • apps/broker/tests/kick-control-plane-skip.test.ts — 5 cases covering the kick/disconnect × control-plane/session/service truth table.

1.34.14 (2026-05-04) — stale CLAUDEMESH_CONFIG_DIR falls back

claudemesh launch exports CLAUDEMESH_CONFIG_DIR=<tmpdir> to its spawned claude so the per-session mesh selection is isolated from ~/.claudemesh/config.json. The tmpdir is rmSync'd on launch exit via the process.on('exit', cleanup) handler.

Footgun: if a later claudemesh invocation INHERITED that env — a Bash tool call inside Claude Code, a tmux pane that captured the env via update-environment, an exported var the user forgot to clear — the inherited path pointed at a tmpdir that no longer existed. Pre-1.34.14 we silently used the dead path, readConfig() came back empty, and the user saw "No meshes joined" from an otherwise-working install. Fish users hit it harder because fish has no unset — they had to discover set -e CLAUDEMESH_CONFIG_DIR.

apps/cli/src/constants/paths.ts now resolves CONFIG_DIR once via a memoized resolveConfigDir():

  1. No env var → ~/.claudemesh (default, unchanged).
  2. Env points at a dir containing config.json → trust it. The legitimate per-session-launch case is byte-identical to before.
  3. Env set but stale (dir gone) → warn once on stderr (TTY-only — CI / MCP boot / piped scripts stay quiet) with a shell-specific unset hint, then fall back to ~/.claudemesh.

The check is on the directory's existence, not on config.json, because a fresh-launch tmpdir legitimately has no config.json until the first write. The stale signature we catch is the outer launch's rmSync(tmpDir, {recursive: true}) cleanup, which removes the directory entirely.

The "no meshes" check from the original triage was deliberately NOT adopted: a launched session that legitimately joins one mesh would hit it.

No back-compat surface affected. No other files changed. _resetPathsForTest() exported for unit tests.

1.34.13 (2026-05-04) — MCP forwards session token on /v1/events

The 1.34.10 SSE demux + 1.34.11 inbox per-recipient column were both in place but the bug user kept seeing wasn't actually fixed. Cause: the MCP server's SSE subscription didn't forward the session token, so the daemon's /v1/events route resolved session to null, the SseFilterOptions filter was empty, and every MCP received the unfiltered global stream. Demux at the bind layer was correct; the subscriber just wasn't telling the daemon who it was.

apps/cli/src/mcp/server.tssubscribeEvents now accepts { sessionToken } and forwards it as Authorization: ClaudeMesh-Session <token> on the SSE connect, identical to how daemonGet and daemonMarkSeen already authenticate. The MCP boot already reads the token via readSessionTokenFromEnv(); this just threads it one more hop. Without this, A's MCP would render DMs that arrived on B's session-WS — exact symptom from the 2026-05-04 two-session smoke, even after restarting the daemon to pick up 1.34.11.

1.34.12 (2026-05-04) — daemon up detaches by default

Pre-1.34.12 claudemesh daemon up ran in the foreground and streamed JSON logs to the terminal until Ctrl-C. Surprising for users who just want the daemon "up" — they'd run it, see a wall of broker_status / broker_ws_open_attempt logs, and not realize the shell was now blocked.

up now spawns a detached child re-execing daemon up --foreground with stdout/stderr redirected to ~/.claudemesh/daemon/daemon.log, then exits the parent cleanly:

$ claudemesh daemon up
  ✔ daemon started (pid 59175)
  → log:  /Users/agutierrez/.claudemesh/daemon/daemon.log
  → stop: claudemesh daemon down

Pass --foreground for the pre-1.34.12 behavior (debugging, or when something else owns lifecycle).

The launchd plist + systemd-user unit + claudemesh launch's auto-spawn helper all explicitly pass --foreground because their parents (launchd / systemd-user / the launch helper) own process lifecycle and stdio redirection. Without that, the child would double-fork and orphan a grandchild the parent service couldn't track.

The parent waits up to 3s for the IPC socket to appear before declaring success; if the child crashes during boot (config read failed, port bind failed, etc.), the parent surfaces the log path instead of silently exiting 0.

Files

  • apps/cli/src/commands/daemon.ts--foreground flag, spawnDetachedDaemon helper, updated help text.
  • apps/cli/src/cli/argv.tsforeground / no-tcp / public-health added to BOOLEAN_FLAGS so the parser doesn't try to consume the next positional as their value.
  • apps/cli/src/entrypoints/cli.ts — threads foreground through to runDaemonCommand.
  • apps/cli/src/services/daemon/lifecycle.ts — auto-spawn passes --foreground (lifecycle helper IS the detacher).
  • apps/cli/src/daemon/service-install.ts — launchd plist + systemd unit pass --foreground (launchd / systemd own lifecycle).

1.34.11 (2026-05-04) — inbox per-recipient column

Closes the storage half of the per-session scoping story 1.34.10 opened. The SSE demux fixed the live event path; this fixes the inbox reads. Same bug shape: every session shared one inbox.db, so any session running claudemesh inbox (and the MCP welcome calling /v1/inbox?unread_only=true) returned the global table — A's launch surfaced B's unread DMs as if they were A's, and markInboxSeen flipped seen-state for rows the asking session never owned.

Schema

apps/cli/src/daemon/db/inbox.ts:

  • New columns: recipient_pubkey TEXT, recipient_kind TEXT, indexed by recipient_pubkey. Migration is non-destructive — pre- 1.34.11 rows land with NULL and are visible to every session on the same mesh (best-effort back-compat).
  • insertIfNew now writes both fields; inbound.ts populates them from the recipientPubkey / recipientKind already passed for the bus event.
  • listInbox accepts recipientPubkey (session) and recipientMemberPubkey (member), composes a WHERE clause: recipient_pubkey IS NULL OR recipient_pubkey IN (session, member).

IPC

apps/cli/src/daemon/ipc/server.ts/v1/inbox resolves the session bearer token to a session pubkey + the matching mesh's member pubkey, threads both into listInbox. Diagnostic callers without a token (no session header) still get the unscoped global view.

The response now surfaces recipient_pubkey + recipient_kind so --json consumers can tell session DMs apart from member-keyed broadcasts.

Welcome auto-fixes

The welcome path already calls /v1/inbox?unread_only=true with the session token; with this scoping in place it now returns ONLY rows the session is meant to see. No code change needed in apps/cli/src/mcp/server.ts.

Architecture invariant after 1.34.11

Every shared store / channel on the daemon now scopes by recipient:

  • EventBus → SSE demux at bind layer (1.34.10)
  • inbox.db → recipient_pubkey / recipient_kind columns + listInbox scoping (1.34.11)
  • outbox.db → already scoped via sender_session_pubkey for routing (1.34.0)

Single bus + single tables remain the canonical pattern; demux is isolated to one chokepoint per layer.

1.34.10 (2026-05-04) — per-session SSE demux + universal daemon

The "echo" the user kept seeing across 1.34.7→1.34.9 wasn't a broker-side echo at all. With two sessions on the same daemon (a + b), the daemon runs ONE event bus shared across every connected MCP. b's session-WS receives a's DM, publishes one message event to the bus, and BOTH a's MCP and b's MCP fan that event into a <channel> reminder. Result: a sees its own outbound rendered with from_pubkey = a.session.pubkey because a's MCP indiscriminately renders every bus event.

Fix is per-subscriber demux at the SSE bind layer (apps/cli/src/daemon/ events.ts). The bus stays single-shot — it just publishes once with recipient context attached. Each /v1/events subscription scopes via the session token presented by the MCP, and the bind helper drops events whose recipient_pubkey doesn't match. System events (peer_join etc.) bypass the recipient check; mesh-scoped events (broker_status with data.mesh) get a mesh-slug filter so a session on prueba1 doesn't see flexicar's broker reconnect lines.

handleBrokerPush (apps/cli/src/daemon/inbound.ts) gains recipientPubkey + recipientKind on its context. Run.ts wires the session-WS path with { recipientKind: "session", recipientPubkey: session.pubkey } and the daemon-WS path with { recipientKind: "member", recipientPubkey: mesh.pubkey }. SSE bind uses the session registry to resolve the subscriber's session pubkey + member pubkey

  • mesh from its bearer token.

The 1.34.8/9 "echo guards" (drop pushes whose senderPubkey/Member === ours) are kept as defense-in-depth; the actual fix lives in the SSE demux. Diagnostic callers without a session token (claudemesh daemon events) get the unfiltered legacy stream — backwards compatible.

Universal daemon (--mesh and --name deprecated)

claudemesh daemon up and daemon install-service no longer accept mesh / name overrides. The daemon attaches to every mesh in ~/.claudemesh/config.json, full stop. Single-mesh isolation is handled by joining only one mesh in that environment (containers, etc.). Pinning at start time was the source of "I joined a new mesh but my service still ignores it" — gone.

--mesh and --name are still parsed for back-compat with existing launchd plists baked at install time, but ignored with a deprecation warning. New installs no longer write them. Help text updated.

Daemon version stamp

daemon_started boot log now includes "version": "1.34.10" so users can grep their daemon log to confirm whether the running process picked up a recent ship. Pairs with the existing claudemesh launch warning that fires when CLI ≠ daemon.

Files

  • apps/cli/src/daemon/events.tsSseFilterOptions, shouldDeliver, bindSseStream(res, bus, filter).
  • apps/cli/src/daemon/inbound.tsrecipientPubkey / recipientKind on InboundContext; bus event carries them through.
  • apps/cli/src/daemon/run.ts — both onPush call sites tag with the right kind; daemon_started boot log includes version.
  • apps/cli/src/daemon/ipc/server.ts/v1/events resolves the bearer session into a filter and passes it to bindSseStream.
  • apps/cli/src/commands/daemon.ts — deprecation warnings on up / install-service for --mesh / --name; help text trimmed.
  • apps/cli/src/entrypoints/cli.ts — top-level help drops --mesh <slug> from the daemon section, adds the universal-daemon note.
  • apps/cli/src/commands/launch.ts — staleness warning copy clean (no misleading --mesh example).

1.34.9 (2026-05-04) — broader self-echo guard + system event polish

Two-session smoke after 1.34.8 surfaced two regressions and one missing piece: echoes still arrived on the daemon-WS path (the 1.34.8 guard was too strict — it required BOTH senderPubkey === ownMember AND senderMemberPubkey === ownMember, but session-attributed echoes carry the session pubkey on senderPubkey); peer_join system events duplicated because both the member-WS and the session-WS forwarded them; and the channel reminder collapsed all peer joins to just a display name with no disambiguation.

Daemon-WS self-echo guard relaxed

apps/cli/src/daemon/run.ts — drop on senderMemberPubkey === ownMember alone. Anything attributed to OUR member is either our own send echoing back via the broker fan-out OR (theoretically) a peer with the same pubkey, which is impossible by construction. Sibling-session DMs fan session-to-session, not via the same member-WS, so they aren't affected.

Session-WS skips system events

apps/cli/src/daemon/session-broker.ts — system pushes (subtype: "system") are dropped before onPush so they don't re-publish on the bus. The member-WS already handles system events; forwarding through both paths produced two [system] Peer "X" joined channel reminders per join, plus another set per sibling session.

Self-join filter on member-WS

apps/cli/src/daemon/inbound.ts — new isOwnPubkey closure on InboundContext. The broker's peer_joined fan-out excludes the JOINING connection but our daemon owns multiple connections per mesh (member-WS + N session-WSs from the same identity), so a session's own peer_joined arrives at the same daemon's member-WS. The filter walks mesh.pubkey plus every live entry in sessionBrokersByPubkey to recognize "us" and drops the event verbatim. Wired in run.ts.

Richer peer-join channel content

apps/cli/src/mcp/server.ts[system] Peer "name" joined the mesh becomes [system] Peer "name" (pubkey-prefix) [groups] joined the mesh — last seen … · "summary" (last-seen + summary fields only on peer_returned events). The meta payload now carries peer_pubkey, peer_groups, peer_last_seen_at, peer_summary for downstream consumers. cwd / role aren't surfaced yet — broker-side change required.

Daemon staleness warning

apps/cli/src/commands/launch.ts — when claudemesh launch finds the daemon already running with a different version than the CLI, it surfaces a one-shot warning + restart instructions. Catches the common "I npm i -gd the latest CLI but the launchd service is still running last week's daemon" footgun.

1.34.8 (2026-05-04) — self-echo guard, inbox read-state + TTL prune

Three closely-related fixes shipped together because they all touch the "what does the user actually see in inbox.db / on the channel" axis.

Self-echo guard

The 1.34.0 sender-attribution fix routed session-originated DMs through the per-session WS so the broker's fan-out attributed each push to the sender's session pubkey. A side effect (visible in the 2026-05-04 two-session smoke): some broker fan-out paths mirror the outbound DM back to the originating session-WS, so the sender saw their own message land in inbox.db, publish a message bus event, and surface as ← claudemesh: <self>: <text> in their own Claude Code session immediately after typing claudemesh send.

Fixed at the WS boundary in two places:

  • apps/cli/src/daemon/session-broker.ts — drop pushes where senderPubkey === opts.sessionPubkey before forwarding to handleBrokerPush. Match on session pubkey only — sibling sessions of the same member share senderMemberPubkey, so a member-level filter would wrongly drop legit sibling DMs.
  • apps/cli/src/daemon/run.ts — daemon-WS onPush drops pushes where BOTH senderMemberPubkey === mesh.pubkey AND senderPubkey === mesh.pubkey (i.e. an actual member-WS self-echo, not a sibling session whose senderPubkey is its session key).

Inbox read-state (seen_at)

Replaces the welcome's "last 24h" window with a proper read-state filter. New seen_at INTEGER column on inbox, plus markInboxSeen and pruneInboxBefore helpers in apps/cli/src/daemon/db/inbox.ts.

Read-state flips on three paths:

  1. Interactive listing — /v1/inbox GET auto-stamps every returned row that was previously NULL. Pass ?mark_seen=false to peek without flipping (used by the welcome — it stamps explicitly only AFTER the channel notification succeeds, so a Zod-rejected welcome doesn't silently lose unread state).
  2. MCP welcome — /v1/inbox?unread_only=true&mark_seen=false&limit=50 surfaces only rows the user hasn't seen, then POST /v1/inbox/seen stamps the ids the welcome actually rendered.
  3. MCP live channel emit — after a successful notifications/claude/channel for a single inbox row, the MCP server calls /v1/inbox/seen for that id so the next launch's welcome doesn't re-surface it.

CLI surface:

claudemesh inbox --unread             # only seen_at IS NULL rows
claudemesh inbox --json               # row now includes seen_at

Inbox TTL prune

apps/cli/src/daemon/inbox-pruner.ts runs pruneInboxBefore(db, Date.now() - 30d) once at daemon startup and hourly thereafter. Logs inbox_prune_completed whenever rows were removed. No CLI knob — it's a built-in retention policy that prevents inbox.db from growing unbounded. Manual override remains claudemesh inbox flush --before <iso>.

Files

  • apps/cli/src/daemon/db/inbox.tsseen_at column + migration, unreadOnly filter, markInboxSeen, pruneInboxBefore.
  • apps/cli/src/daemon/inbox-pruner.ts — new file, hourly TTL sweep.
  • apps/cli/src/daemon/run.ts — wires the pruner into startup + shutdown; daemon-WS self-echo guard.
  • apps/cli/src/daemon/session-broker.ts — session-WS self-echo guard.
  • apps/cli/src/daemon/ipc/server.tsunread_only + mark_seen query params; new POST /v1/inbox/seen route.
  • apps/cli/src/mcp/server.tsdaemonMarkSeen helper; welcome switched to unread_only=true; mark-seen after channel emit.
  • apps/cli/src/services/bridge/daemon-route.tstryListInboxViaDaemon accepts { unreadOnly, markSeen }; InboxItem.seen_at exposed.
  • apps/cli/src/commands/inbox.ts + apps/cli/src/entrypoints/cli.ts
    • apps/cli/src/cli/argv.ts--unread flag.
  • apps/cli/skills/claudemesh/SKILL.md — documents seen_at semantics, self-echo guard, TTL prune.

1.34.7 (2026-05-04) — inbox flush + delete commands

The CLI had no first-class way to clean the persisted inbox; the only recourse was sqlite3 ~/.claudemesh/daemon/inbox.db "DELETE FROM inbox", which bypasses IPC and is invisible to anyone who doesn't know the schema. Two new verbs:

claudemesh inbox flush --mesh prueba1
claudemesh inbox flush --before 2026-05-04T18:00:00Z
claudemesh inbox flush --all                # required guard with no other filter
claudemesh inbox delete <message-id>        # alias: rm
claudemesh inbox flush --json               # → { ok: true, removed: N }

flush without filters refuses with an --all confirmation hint — prevents an accidental "wipe every row on every mesh" from a fat-fingered command.

Mechanics

  • apps/cli/src/daemon/db/inbox.ts gains deleteInboxRow(id) and flushInbox({ mesh?, before? }) (returns changes).
  • IPC: DELETE /v1/inbox?mesh=…&before=… + DELETE /v1/inbox/<id>. Mesh filter honors session-default scoping (same as listing).
  • Daemon-route helpers tryFlushInboxViaDaemon and tryDeleteInboxRowViaDaemon mirror the existing tryListInboxViaDaemon shape.
  • New CLI command file apps/cli/src/commands/inbox-actions.ts.
  • Help and SKILL.md document the verbs.

1.34.6 (2026-05-04) — welcome: stringify meta values to pass Zod schema

The 1.34.2 → 1.34.5 timing-race theory was wrong. Reading Claude Code v2.1.126's binary at the notifications/claude/channel schema:

IJ_ = y.object({
  method: y.literal("notifications/claude/channel"),
  params: y.object({
    content: y.string(),
    meta: y.record(y.string(), y.string()).optional(),
  }),
})

meta is a record(string, string) — every value MUST be a string. Pre-1.34.6 the welcome shipped:

  • peer_count: number → Zod reject
  • peer_names: string[] → Zod reject
  • unread_count: number → Zod reject
  • latest_message_ids: string[] → Zod reject

The whole notification was dropped at the schema-validation step BEFORE the channel handler ever ran. No log, no error, no UI surface — exactly the symptoms 1.34.2 → 1.34.5 chased.

Live peer DMs always worked because every meta value already went through String(...). The welcome was the only notification shape with non-string meta, uniquely affected.

Fix

emitMeshWelcome now coerces every meta value to string. Counts become digit strings ("3", "16"); arrays serialize as JSON ('["b","c"]', parseable on the receiving side). Schema validation passes, notification reaches the handler, channel reminder surfaces.

The 1.34.5 dual-lane retry is removed — single emit at 3s grace after oninitialized is enough now that the schema is right.

What changed in ~/.claudemesh/daemon/mcp-<pid>.log

welcome_attempt rows are gone (no more lanes). You'll see mcp_startedserver_initializedwelcome_peers_resolvedwelcome_emitted per launch — the same shape as 1.34.4 minus the fast/slow lane field.

1.34.5 (2026-05-04) — dual-lane welcome retry to defeat handler-registration race

1.34.4 hooked server.oninitialized + 2s grace. The MCP-side log confirmed welcome_emitted ran at +2.4s, but the user still saw nothing in Claude Code. Claude Code's React effect that calls setNotificationHandler("notifications/claude/channel", ...) runs multiple ticks AFTER its UI state flips to "connected", which happens after server.oninitialized fires. 2s was still inside the dead zone.

We can't directly observe handler-registration timing from the MCP side (the SDK has no hook for it), so this version emits the welcome TWICE: 5s post-init (lane: "fast") and 15s post-init (lane: "slow"). Whichever one lands surfaces; the duplicate is acceptable for an informational welcome. Both attempts log to mcp-<pid>.log so we can see which lane wins in production. If observation shows the slow path always wins, future versions can drop the fast attempt.

1.34.4 (2026-05-04) — welcome triggers on oninitialized, peer count fix

Welcome trigger: post-initialization, not fixed timer

1.34.3's welcome fired on a fixed 5s timer after server.connect. Diagnostic logging confirmed the emit ran (welcome_emitted in mcp-<pid>.log) but the user never saw the channel reminder. Cause: Claude Code only registers its notifications/claude/channel notification handler AFTER the MCP init handshake completes (initialize request → initialized notification from client → server.oninitialized fires). 5s commonly closed before that sequence finished, so the welcome notification arrived at a server that hadn't wired up a handler yet — silently dropped.

Live peer DMs worked because they arrive seconds-to-minutes later, well past the window. The welcome is the only event with a deterministic close-to-zero delay, so it was uniquely affected.

The fix gates the welcome on Server.oninitialized, then adds 2s of grace for any pending list_tools / list_prompts round-trips to settle before emitting. Matches the registration timing exactly — by the time oninitialized fires, Claude Code has already accepted the server and registered the channel handler.

Peer count filter mirrors the launch banner

The 1.34.3 welcome used peerRole !== "control-plane" to filter the peer list — that's the new taxonomy from broker M1, but older brokers still emit only channel: "claudemesh-daemon" for control-plane rows. Result: peer_count: 0 even when the launch banner showed "2 peers online". The welcome filter now matches the launch banner exactly (channel !== "claudemesh-daemon") and additionally honors peerRole !== "control-plane" when present.

Self-exclusion is now opt-in: only filtered when self_session_pubkey is known (from the /v1/sessions/me lookup). This prevents over- filtering when the token route fails and we fall back to the unauthenticated peer list.

mcp-<pid>.log now records server_initialized, welcome_peers_resolved (with total / real counts), and welcome_peers_status so a missing welcome can be traced through the init handshake → peer query → notification chain.

1.34.3 (2026-05-04) — welcome always fires + skill / help refresh

Welcome always emits, regardless of inbox state

The 1.34.2 welcome only fired when there were unread messages, so a freshly-launched session with an empty inbox saw nothing — no confirmation that the mesh pipe was live. Now it always emits, and carries useful launch context:

  • identity — display name, session pubkey prefix, role
  • mesh — active mesh slug
  • peers — live peer count + up to 5 names (control-plane filtered out)
  • inbox — recent count + up to 3 previews (or "Inbox is empty (last 24h)")
  • CLI hintspeer list · send · inbox
  • skill pointer📚 Read the claudemesh skill (SKILL.md)… so the model loads the canonical reference if it isn't already in context

Composes from up to three best-effort daemon queries (/v1/sessions/me, /v1/peers?mesh=…, /v1/inbox?mesh=…&since=…), each degrading silently. The welcome ALWAYS goes out unless the IPC socket is unreachable. Meta carries kind: "welcome", self_display_name, self_session_pubkey, self_role, mesh_slug, peer_count, peer_names, unread_count, and latest_message_ids for downstream consumers.

daemonGet now forwards the session token

The MCP's IPC client gained an optional sessionToken field. The welcome path uses it for /v1/sessions/me (which 401s without auth) and for default-mesh scoping on /v1/peers and /v1/inbox. Token read from CLAUDEMESH_IPC_TOKEN_FILE set by claudemesh launch.

Skill (apps/cli/skills/claudemesh/SKILL.md) refresh

  • New section: "Launch welcome (kind: "welcome")" — describes the 5-second handshake, its meta fields, and that it should NOT be replied to like a DM.
  • Channel attributes table: clarified that from_pubkey is the ephemeral session pubkey of the originator (post-1.34.0 attribution fix), separated from_session_pubkey and from_member_pubkey, added client_message_id and kind rows.
  • Inbox section: documented --mesh <slug>, --limit N, and that the command reads ~/.claudemesh/daemon/inbox.db via daemon IPC (NOT a fresh broker-WS buffer drain — that path was removed in 1.34.0).
  • Reply behavior: explicit "do NOT reply when meta.kind is "welcome" or "system"".

claudemesh --help refresh

message inbox line was still labeled "drain pending" from the pre-1.34.0 cold-path implementation. Updated to "read persisted inbox" with the new flags (--mesh, --limit, --json) and a note that it reads from ~/.claudemesh/daemon/inbox.db via the daemon.

1.34.2 (2026-05-04) — launch welcome push summarizing recent inbox

When a Claude Code session launches via claudemesh launch, the user lands cold — they don't know whether peers messaged them while they were offline. Real-time pushes only cover messages that arrive AFTER the SSE subscription is alive, so anything queued at the broker that drains during the hello-handshake window can land in inbox.db before the MCP subscribes. Without a welcome, the user has to remember to run claudemesh inbox to discover the gap.

The MCP server now fires a one-shot welcome 5s after the transport is up:

  • queries /v1/inbox?since=<24h-ago>&limit=20 for the recent window;
  • skips silently when there are no rows;
  • emits a single notifications/claude/channel with header (📥 [welcome] N messages from last 24h (mesh-a, mesh-b)), up to three preview lines (sender, mesh, time, 60-char body), a remainder count, and the claudemesh inbox CLI hint;
  • meta carries kind: "welcome", unread_count, mesh list, and the first 10 message ids so a downstream agent can claudemesh message status <id> if it wants to inspect.

Why a 5s delay: gives the daemon's session-WS time to reconnect, re-claim leased rows, drain pending broker queue, and finish writing to inbox.db before we summarize. Earlier and the welcome would under-report; later and it stops feeling like a launch handshake.

Why a 24h window: narrow enough to feel relevant on a freshly-launched session, wide enough to surface overnight messages without dumping the entire history into the channel.

The welcome flow is fully diagnostic — welcome_skip (with reason), welcome_emitted, or welcome_emit_failed lands in ~/.claudemesh/daemon/mcp-<pid>.log for every launch.

1.34.1 (2026-05-04) — declare claude/channel capability so Claude Code surfaces pushes

The 1.34.0 ship fixed the daemon-side push pipeline (correct sender attribution, persistent inbox readable from CLI). Bus events fire, SSE delivers them to the MCP, and the MCP calls server.notification("notifications/claude/channel", ...) — but Claude Code v2.1.x stopped surfacing them as <channel> reminders. Real two-session smoke confirmed the silent drop: messages landed in inbox.db, the daemon SSE stream emitted the message events, yet neither Claude Code session got a real-time push.

Root cause

Claude Code v2.1.x added a capability gate on the channel handler. Reading claude binary at the notifications/claude/channel offsets:

function xJ_(serverName, capabilities, pluginSource) {
  if (!capabilities?.experimental?.["claude/channel"])
    return { action: "skip", kind: "capability",
             reason: "server did not declare claude/channel capability" };
  ...
}

xJ_ is called when the MCP server connects. When it returns {action: "skip"}, Claude Code never calls client.setNotificationHandler(IJ_(), ...) for that server — so every notifications/claude/channel emit falls into the void. The --dangerously-load-development-channels server:claudemesh flag gets you past the allowlist check that runs LATER in xJ_, but the capability gate runs FIRST and is independent.

Pre-2.1.x clients didn't gate on this key, which is why the same MCP wire shape "worked" before. There's no error / log / warning on either side; the notifications just disappear.

Fix

apps/cli/src/mcp/server.ts declares the capability:

new Server({ name: "claudemesh", version: VERSION }, {
  capabilities: {
    tools: {}, prompts: {}, resources: {},
    experimental: { "claude/channel": {} },
  },
});

The empty object is enough — Claude Code only checks for presence, not contents.

Diagnostic logging

The MCP server now writes a per-pid log to ~/.claudemesh/daemon/mcp-<pid>.log whenever:

  • the SSE event arrives (sse_event_received),
  • a channel notification is emitted (channel_emitted), or
  • the emit throws (channel_emit_failed).

tail -f ~/.claudemesh/daemon/mcp-*.log lets users verify the push pipeline end-to-end without strings-dumping the Claude Code binary. (MCP stderr is captured by Claude Code and not visible to the user, so an on-disk log was the only way to surface this state in the future.)

Upgrade

npm i -g claudemesh-cli@latest
# Restart Claude Code so the MCP picks up the capability change.

After this version: peer messages surface as <channel> reminders mid-turn the way they did pre-2.1.x.

1.34.0 (2026-05-04) — Sender attribution via session-WS + inbox CLI fix

Two regressions surfaced in real two-session smokes that landed together; both root in the same architectural seam (sender identity across the daemon ↔ broker ↔ recipient hop).

Sender attribution: outbox routes via session-WS

Pre-1.34.0, every outbox row drained through the daemon's member-keyed DaemonBrokerClient, regardless of which session typed claudemesh send. The broker's fan-out builds the push envelope from conn.sessionPubkey ?? conn.memberPubkey — for a member-WS that's always the member pubkey. Result: a real two-session smoke (a → b: "123", b → a: "456") landed messages in inbox.db with sender_pubkey = <daemon's member pubkey> instead of the actual session sender's ephemeral pubkey. Wrong "from" for every DM.

The fix routes session-originated sends through the matching SessionBrokerClient so the broker sees conn.sessionPubkey = <sender session pubkey> naturally — no broker-side change needed. Mechanics:

  • New outbox.sender_session_pubkey column. The IPC /v1/send handler fills it whenever the request authenticates as a launched session (Authorization: ClaudeMesh-Session …).
  • IPC /v1/send now encrypts with the session secret (was: mesh member secret) when a session token is present. Recipient's inbound.ts decrypts with senderSessionPub × recipientSessionSec → matches what the sender wrote.
  • SessionBrokerClient gains a send() method mirroring DaemonBrokerClient.send (pendingAcks tracking, 15s ack-timeout, queue-while-reconnecting via the opens array). Composition kept intact — both clients share connectWsWithBackoff; the request/ack bookkeeping is duplicated rather than subclassed.
  • Drain worker reads sender_session_pubkey and looks up an open session-WS via a new getSessionBrokerByPubkey accessor on DrainOptions. Session-attributed rows REQUIRE an open session-WS; no fallback to daemon-WS, because the row is encrypted with the session secret and silent fallback would break decryption on the recipient side. Closed/reconnecting → backoff + retry.
  • apps/cli/src/daemon/run.ts maintains a parallel sessionBrokersByPubkey index alongside the existing token-keyed map, kept in sync on register/deregister.

Cold-path sends (no session token in IPC headers) keep the legacy member-key flow unchanged. Pre-1.34.0 outbox rows (NULL session pubkey) drain via the daemon-WS as before — no migration of in-flight rows is required.

claudemesh inbox reads inbox.db (was: stale broker buffer)

The pre-1.34.0 implementation opened a fresh BrokerClient, waited 1s, then drained an in-memory push buffer that would only contain new pushes received during that 1s window — completely disjoint from the daemon's persisted ~/.claudemesh/daemon/inbox.db. So with the attribution bug above, a real smoke that DID land messages in the daemon's inbox.db reported "No messages on mesh prueba1" because the CLI was looking at the wrong source.

Fixed:

  • New tryListInboxViaDaemon(mesh, limit) daemon-route helper hits /v1/inbox.
  • listInbox (DB layer) and the /v1/inbox IPC endpoint accept a mesh filter so the server scopes server-side instead of returning all rows and filtering in-process.
  • runInbox rewritten to call the daemon-route helper. JSON mode returns the raw daemon shape; the human renderer formats sender name + pubkey prefix + body + receipt time per row.

The cold-path "drain a fresh-broker buffer" was always vestigial — removed entirely.

Verifying

/tmp/cm-bus-trace.mjs (workshop scratch, not shipped) opens an SSE listener against /v1/events, registers two test sessions, sends both directions, and asserts the broker message events surface correctly. Used to confirm the daemon's bus.publish path was already fine — the regression sat upstream in the daemon's outbound attribution.

After this version: real two-session smokes show sender_pubkey = <session pubkey> (not member pubkey), claudemesh inbox --mesh <slug> lists what the daemon actually received, and existing MCP notifications/claude/channel events carry the correct sender attribution to Claude Code.

1.33.0 (2026-05-04) — Milestone 1: lifecycle cleanups + at-least-once with ack

First milestone of the agentic-comms architecture work (.artifacts/specs/2026-05-04-agentic-comms-architecture-v2.md). Foundational correctness — no new external surface, but the wire protocol grows two additions: a peerRole field on peer list responses (presence classification) and a new client-→broker client_ack frame.

Lifecycle helper extraction

DaemonBrokerClient and SessionBrokerClient now share a single lifecycle implementation in apps/cli/src/daemon/ws-lifecycle.ts (connectWsWithBackoff). Each client supplies buildHello / isHelloAck / onMessage and keeps its own RPC bookkeeping; the helper handles connect, hello-ack timeout, close + backoff reconnect. Composition over inheritance per Codex's review. Eliminates the drift bug class that produced 1.32.0/1.32.1 (lifecycle copies diverging silently when one side gained a feature).

Daemon-WS no longer carries an ephemeral session keypair

Pre-1.33: every daemon-WS reconnect minted a fresh keypair, sent the pubkey in the hello, and held the secret in memory for "session" crypto. Vestigial since 1.30.0 introduced the per-launch SessionBrokerClient that owns the real session pubkey. Daemon-WS now uses the stable mesh member secret directly for outbound encryption. Inbound on daemon-WS only attempts member-key decryption — session decryption is the session-WS's job.

peerRole wire field

The broker now emits a peerRole field on each peer list row — 'control-plane' | 'session' | 'service'. control-plane rows are the daemon's own member-keyed presence (infrastructure), session rows are launched Claude Code sessions, service rows are reserved for v2.x service identities (HTTP webhook consumers, voice agents, etc.).

The CLI hides peerRole === 'control-plane' rows from the human renderer by default and exposes a --all flag for debugging. JSON output emits peerRole on every row.

Why peerRole and not just role: 1.31.5 already lifted profile.role (user-supplied string like "lead", "reviewer") to top-level role, and the agent-vibes claudemesh skill consumes that field. The presence classification is a different axis, so it gets its own field name. role keeps its 1.31.5 semantics; peerRole is the new field.

client_ack and at-least-once delivery

The broker (M1 broker change) now uses two-phase claim/deliver: claimed_at / claim_id / claim_expires_at columns track lease ownership; delivered_at is set ONLY when the recipient acks. A 15s sweeper re-claims rows whose 30s lease expired without ack.

The CLI side closes the loop: after handleBrokerPush lands a message in inbox.db (or dedupes against an existing row), the recipient daemon emits a client_ack { type: "client_ack", clientMessageId, brokerMessageId? } frame on whichever WS the push arrived on. Best-effort — if the WS is closed by ack time, the broker's lease will naturally re-deliver, and the receiver dedupes on clientMessageId.

Net behavior: at-least-once with idempotent dedupe. Net visible change: zero, in the steady state. Crash-mid-push test (kill recipient between broker claim and recipient ack) now redelivers instead of silently dropping.

Files

  • New: apps/cli/src/daemon/ws-lifecycle.ts (234 lines).
  • Refactored: apps/cli/src/daemon/broker.ts, session-broker.ts, inbound.ts, run.ts, commands/peers.ts, ipc/server.ts.
  • Broker side (separate commit): drain race fix, presence.role column, client_ack handler, lease sweeper.
  • DB migration 0029_drain_lease_and_presence_role.sql ships with the broker change.

Foundational refactor before the agentic-comms architecture work (.artifacts/specs/2026-05-04-agentic-comms-architecture-v2.md). Three changes, all behavior-preserving:

  • connectWsWithBackoff helper (apps/cli/src/daemon/ws-lifecycle.ts). Both DaemonBrokerClient and SessionBrokerClient now share one lifecycle implementation — connect, hello-handshake, ack-timeout, close + backoff reconnect. Each client supplies buildHello / isHelloAck / onMessage and keeps its own RPC bookkeeping (pendingAcks, peerListResolvers, onPush, etc). Composition over inheritance per Codex's review; no protocol shape changes.

  • Drop daemon-WS ephemeral session pubkey. DaemonBrokerClient no longer mints + sends a per-reconnect ephemeral keypair in its hello. Session-targeted DMs land on SessionBrokerClient (since 1.32.1), not the member-keyed daemon-WS, so the field was vestigial. The daemon's send-encrypt path now signs DMs with the stable mesh member secret. Inbound on daemon-WS only attempts member-key decryption — session decryption is the session-WS's job.

  • Role-aware peer list. peer list now hides peers whose broker-emitted role is 'control-plane' (the daemon's own member-keyed presence). --all opts back in. JSON output emits role at the top level. Older brokers that don't emit role yet default to 'session', so legacy peer rows stay visible without the broker-side change shipped first. Replaces the prior peerType === 'claudemesh-daemon' channel-name hack.

1.32.1 (2026-05-04) — DMs to session pubkeys actually deliver now

Critical fix. Sessions launched via claudemesh launch (1.30.0+) hold a per-launch session WebSocket on the broker, separate from the daemon's member-keyed WS. The broker correctly fans direct messages targeted at a session pubkey out over THAT session WS — but the daemon's SessionBrokerClient was constructed without a push handler and silently dropped every inbound push / inbound frame. The header docstring even claimed it handled "inbound DM delivery for messages targeted at the session pubkey"; the code never wired the callback.

Net effect since 1.30.0: any DM sent to a peer's session pubkey (everything peer list returns these days, since session pubkey is the canonical routing key) was queued, broker-acked, marked delivered_at on the broker side, and then thrown away by the recipient daemon. The local inbox.db stayed at zero rows forever and claudemesh inbox reported "no messages" no matter what arrived.

Two-session smoke test that surfaced this: peer A sent "hola" to peer B's session pubkey — sender outbox showed status=done with a broker_message_id, recipient inbox stayed empty, both sides confused.

The fix wires SessionBrokerClient to forward push / inbound frames to the same handleBrokerPush the member-keyed broker already uses. The session's secret key (registered via /v1/sessions/register) is passed as sessionSecretKeyHex so decryptOrFallback tries it first; the parent member key remains the fallback for legacy member-targeted traffic that happens to fan out here.

Files: apps/cli/src/daemon/session-broker.ts, apps/cli/src/daemon/run.ts. No broker change required — the broker half (queue + fan-out + sendToPeer on the session WS) was already correct; only the daemon-side intake was missing.

1.32.0 (2026-05-04) — multi-session UX bundle

Nine UX bugs surfaced from a real two-session interconnect smoke test shipped together as a single release.

Self-identity is now visible

  • peer list includes the calling session as a row, marked (this session), sorted to the top. The daemon path now resolves the caller's session pubkey via /v1/sessions/me so isThisSession is set correctly even when running warm. (Previously the row was present but indistinguishable, and the daemon path always set isThisSession=false.)
  • whoami shows in-session identity when run inside a launched session: session pubkey (truncated + full), session id, mesh, role, groups, cwd, pid. Previously whoami only reported web sign-in state.

Sibling-session disambiguation

  • peer list rows now carry a sid:<short> tag so two visually-identical rows (same name, same cwd) can be told apart at a glance.
  • JSON output already had sessionId; the human renderer surfaces a short prefix.

Daemon presence hidden by default

  • claudemesh-daemon rows used to clutter peer list and confused users into thinking the daemon counted as a peer. They're now hidden in the human renderer; --all opts back in for debugging. The header line shows (N peers, M daemon hidden — use --all) when applicable. JSON output is unchanged.

--self flag works end-to-end

  • Argv parser bug fixed. --self was being parsed greedily — every --flag consumed the next non-- arg as its value, so claudemesh send --self <pubkey> "msg" ate the pubkey as the value of --self and left zero positionals. A BOOLEAN_FLAGS set in cli/argv.ts now lists known no-value switches (self, json, all, quiet, yes, strict, force, dry-run, etc.). --flag=value form also recognized for explicit overrides.
  • message send subcommand now passes self through to runSend (only the legacy send form had been wired).
  • Help text updated to list --self (and --priority, --mesh, --json) under claudemesh message send.

Member-pubkey fan-out

  • Sending to your own member pubkey with --self now fans out to every connected sibling session of your member. Previously the broker drain query at apps/broker/src/broker.ts:2408 matched target_spec only against full session pubkeys, so member-pubkey sends queued successfully but no recipient drain ever fetched. The CLI now resolves the member pubkey to all sibling session pubkeys via the peer list and sends one message per recipient. Output reports fanned out to N sibling sessions with per-recipient ack/error.

Broker welcome at launch

  • After the launch banner, a single line confirms WS connectivity:

    ● broker connected · 6 peers online · 0 unread
    

    Hits /v1/health for broker WS state, peer list (daemon-cached) for peer count, and /v1/inbox for unread. All best-effort — falls back gracefully if any call fails so launch never blocks on it.

1.31.6 (2026-05-04) — hex-prefix sends actually deliver now

claudemesh send <16-hex-prefix> "..." would acknowledge with sent to <prefix> (daemon) but the recipient never received the message. The broker's pre-flight matched peer.pubkey === targetSpec and the drain query matched target_spec = <full-pubkey> — both exact-equal checks, so a 16-hex prefix queued successfully but no recipient drain ever fetched the row. Sender saw "sent", recipient saw nothing.

Fix: the CLI now resolves any hex prefix (4-63 chars, not full 64) to the full pubkey via the daemon's peer list before submitting to the broker. Three outcomes:

  • Unique match: prefix is canonicalized to the full 64-char pubkey; the rest of the send pipeline is unchanged.
  • No match: clear error No peer matches hex prefix "X" with the list of online peers' display names.
  • Multiple matches: clear error listing the candidates and a hint to lengthen the prefix.

The 16-hex prefix shown in peer list rows is now safe to copy-paste into claudemesh send — what worked in the docs finally works in the CLI.

1.31.5 (2026-05-04) — JSON peer list lifts profile.role to top-level + skill guides LLMs to render it

Two follow-ups after 1.31.4 made the human renderer show role/groups but a launched-session LLM still dropped them when it called peer list --json and built its own table.

  • Top-level role field on every peer record. The broker has always returned role nested under profile.role, but downstream consumers (LLMs in launched sessions, jq pipelines, dashboards) kept missing it. The CLI now lifts profile.role to a top-level role field at parse time, so it's the second thing visible in JSON after displayName. The original profile.role is preserved for backward compatibility.
  • Updated SKILL.md peer-list section with the full JSON shape (including memberPubkey, sessionId, role, profile, isSelf, isThisSession) and explicit guidance: when listing peers inside a launched session, prefer the human renderer; if you do need JSON, always include role and groups columns. The previous version of the skill documented six fields and skipped role + identity entirely.

1.31.4 (2026-05-04) — peer list shows roles and groups

claudemesh peer list now surfaces each peer's profile-level role (claudemesh profile) and any joined groups inline next to the display name, e.g.

● mou [role:lead, @flexicar:reviewer, @oncall] (ai, claude-code) · 0d215762…
   cwd: /Users/agutierrez/Desktop/claudemesh

When both role and groups are empty, an explicit footer is added so absence is unambiguous instead of looking like the CLI is hiding the field:

● peer [...]
   role: (none)  groups: (none)

JSON output is unchanged (the broker has surfaced these fields all along) — only the human renderer was missing them.

1.31.3 (2026-05-04) — clean rebuild of 1.31.2

1.31.2 published with the right code change but a stale baked-in VERSION string ("1.31.1") because the build ran before the version bump. Same fix as 1.31.2, rebuilt cleanly.

1.31.2 (2026-05-04) — daemon paths no longer follow per-session CLAUDEMESH_CONFIG_DIR

Production bug observed in real installs: every CLI verb invoked from inside a claudemesh launch-spawned session printed

[claudemesh] warn service-managed daemon not responding within 8000ms

even when the launchd-managed daemon was healthy and responding to direct probes in ~10 ms.

Root cause: claudemesh launch exports CLAUDEMESH_CONFIG_DIR to a per-session tmpdir so that joined-mesh state and the session IPC token stay isolated from the host's shared config. DAEMON_PATHS read its base directory from the same env var, so inside a launched session the CLI looked for daemon.sock at e.g. /var/folders/.../claudemesh-XXXX/daemon/daemon.sock — which never exists. The CLI declared the daemon down, fell into the service-managed wait branch, and timed out.

The daemon is a per-machine singleton serving every session; its files belong at ~/.claudemesh/daemon/ regardless of any per-session overlay. Fix: pin DAEMON_PATHS.DAEMON_DIR to ~/.claudemesh/daemon/ and ignore CLAUDEMESH_CONFIG_DIR. A new CLAUDEMESH_DAEMON_DIR override is preserved for tests / multi-daemon dev setups; production callers should never set it.

After this fix, all CLI verbs from within a launched session take the warm-path (~10 ms IPC) again instead of the cold path (~600-1200 ms).

1.31.1 (2026-05-04) — hotfix: reaper stops blocking the daemon event loop

1.31.0 shipped a session reaper that called execFileSync("ps") synchronously, once per registered session, every 5 seconds. With ten or more sessions registered the daemon's event loop stalled for hundreds of milliseconds at a time — long enough that incoming /v1/version probes from the CLI failed to return within the 2.5 s budget and the new "service-managed daemon not responding within 8000ms" warning fired against a perfectly healthy daemon.

Fix:

  • getProcessStartTime is now async (execFile + promisify), never blocks the event loop.
  • New getProcessStartTimes(pids) issues a single batched ps -p <p1>,<p2>,... for every survivor in one fork — sweep cost is fixed regardless of session count.
  • registerSession stays synchronous: the start-time capture runs fire-and-forget so the IPC route returns instantly. The reaper falls back to bare liveness for the brief window before the start-time lands.
  • reapDead is now async; the setInterval wrapper voids it so a rejected sweep can never crash the daemon.

Behavior is otherwise unchanged from 1.31.0 — same 5 s cadence, same PID-reuse guard semantics, same broker-WS teardown via the registry hook.

1.31.0 (2026-05-04) — session autoclean, install-time broker verification, no more spurious cold-path warnings under service management

Three operability changes targeting users who installed the daemon as a launchd / systemd service.

Session reaper now autocleans dead claude-code sessions

The daemon's session registry already had a 30-second reaper that deregistered entries whose pid was dead, but it had two gaps:

  • Sweep cadence too slow. Stale presence on the broker lingered for up to half a minute after a session crashed.
  • No PID-reuse guard. A recycled pid passes kill(pid, 0) even though the original process is gone, so the registry could trust a ghost.

Process-exit IPC from claude-code itself isn't a viable replacement — exit handlers don't run on SIGKILL, OOM, segfault, kernel panic, or power loss. The reaper has to be the source of truth.

What changed:

  • Reaper interval 30 s → 5 s.
  • On register, capture an opaque process start-time (ps -o lstart=, works on macOS and Linux). Stored alongside the pid.
  • On each sweep, an entry is reaped when the pid is dead or the pid is alive but its start-time no longer matches what we captured.
  • Registry hooks already close the per-session broker WS on deregister, so peer list rebuilds within one sweep of any session exit, no matter how the process died.

Local-host scope only — cross-host registrations are skipped (the daemon can't kill -0 a remote pid). Best-effort fallback to bare liveness when start-time capture fails (e.g., process already gone at register time).

Service-managed daemon: no more "spawn failed" false alarms

Users who installed via claudemesh install (which sets up launchd/systemd with KeepAlive=true) saw spurious warnings:

[claudemesh] warn daemon spawn failed: socket did not appear within 3000ms

even when the daemon was healthy. Two contributing causes:

  1. Probe timeout was 800 ms. Tight enough that the first IPC after a launchd-driven restart (which migrates SQLite + opens broker WSes) routinely tripped it. Bumped to 2500 ms.
  2. CLI raced launchd on respawn. When the probe failed, the CLI tried to spawn its own detached daemon, which collided with launchd's own restart cycle (singleton lock fails, child exits) and left the user with a 3-second timeout warning. Now: when the daemon is installed as a service unit (~/Library/LaunchAgents/com.claudemesh.daemon.plist or ~/.config/systemd/user/claudemesh-daemon.service exist), the CLI does not attempt to spawn. It waits up to 8 s for the OS to bring the socket up, and only fails out with a service-specific message pointing at launchctl print / systemctl status if the service genuinely failed.

New state service-not-ready distinguishes "OS-managed daemon hasn't come up yet" from "we tried to spawn and it failed" — the latter no longer fires when the daemon is service-managed.

claudemesh install now verifies broker connectivity, not just process start

Previously install ended once launchctl/systemctl reported the unit loaded — but a daemon that boots and then can't reach the broker (blocked outbound :443, expired TLS, DNS failure, broker outage) only surfaced as a confusing failure on the user's first peer list or send, sometimes hours later.

/v1/health was extended to include per-mesh broker WS state:

{ "ok": true, "pid": 58837, "brokers": { "flexicar": "open", "openclaw": "connecting" } }

After service start, install polls /v1/health for up to 15 s and prints either:

✔ broker connected (mesh=flexicar, 2 other meshes attaching)

or, on timeout:

warn  broker did not reach open within 15s (flexicar=connecting, openclaw=connecting)
      Check ~/.claudemesh/daemon/daemon.log for connect errors.
      Common causes: outbound :443 blocked, expired TLS, DNS resolution.

The verification is best-effort and doesn't fail the install — it just surfaces the issue early so the user can fix it before sending their first message.

Tests

4 new vitest cases cover the reaper paths: dead pid, live pid + matching start-time, live pid + mismatched start-time (PID reuse), and the no-start-time best-effort fallback.

1.30.2 (2026-05-04) — daemon service is multi-mesh by default

claudemesh install was hardcoding --mesh <primaryMesh> into the launchd plist / systemd unit, which locked the daemon to a single mesh and contradicted 1.26.0's multi-mesh design (one daemon attaches to every joined mesh on boot).

Net effect for users with more than one joined mesh: every CLI verb against a non-primary mesh fell off the daemon path back to cold-WS and re-handshakes a fresh broker connection on each call. Most visible symptom is [claudemesh] warn daemon spawn failed: socket did not appear within 3000ms when a launched session asks for peers in a sibling mesh, plus peer list --mesh foo returning peers from every attached mesh because the server-side filter never ran.

Now: install drops the --mesh arg entirely so the unit launches claudemesh daemon up (no flag), which attaches to every joined mesh. claudemesh daemon install-service --mesh <slug> is preserved for users who want to pin to one mesh (CI, single-mesh hosts).

1.30.1 (2026-05-04) — daemon install upgrade-safe + node-pinned

Two install-path fixes that bit on first user upgrade:

  • Pin node by absolute path in the launchd plist / systemd unit. The bin script's #!/usr/bin/env node shebang resolves against the service environment's PATH, which on macOS launchd defaults to /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin. That picks up whatever Node is installed system-wide instead of the Node that ran claudemesh install — and Node 22.x doesn't expose node:sqlite without the experimental flag, so the daemon crashed with db open failed: ERR_UNKNOWN_BUILTIN_MODULE. Now we write process.execPath as the first ProgramArgument so the daemon always runs under the same Node that installed it.
  • Tear down the old daemon before re-bootstrapping. claudemesh install on a machine that already has a running daemon was hitting Bootstrap failed: 5: Input/output error because launchctl refuses to bootstrap a unit that's already loaded, and the old daemon process held the singleton lock. The install path now runs launchctl bootout (or systemctl --user stop) first, plus a SIGTERM to any orphaned daemon pid in ~/.claudemesh/daemon/ daemon.pid, so subsequent installs replace cleanly.

1.30.0 (2026-05-04) — per-session broker presence

Sprint A Phase 3. Two claudemesh launch sessions in the same cwd now see each other in peer list. Each launched session has a long-lived broker presence row owned by the daemon, identified by a per-launch ephemeral keypair vouched by the member's stable key (OAuth-refresh-vs- access shape).

What landed

  • broker session_hello — new WS message type. Validates a parent-vouched parent_attestation (≤24h TTL, ed25519 signature by the parent member) plus a session-keyed signature on the hello itself. Inserts a presence row keyed on sessionPubkey but member_id from the parent, so member-targeted operations stay unchanged. Older brokers reply unknown_message_type — newer clients drop back to the previous behavior.
  • daemon SessionBrokerClient — slim WS variant of DaemonBrokerClient. Presence-only, no outbox drain. Lifetime tied to a registry hook: register opens it, deregister/reaper closes it. Reconnect with exponential backoff up to 30 s.
  • session-registry hookssetRegistryHooks({ onRegister, onDeregister }) in apps/cli/src/daemon/session-registry.ts. Hook errors are caught so they never throttle the registry. SessionInfo gains an optional presence field carrying the per-launch keypair
    • attestation.
  • IPC POST /v1/sessions/register — accepts an optional presence block on the body (session_pubkey, session_secret_key, parent_attestation). Older payloads continue to work.
  • claudemesh launch — generates an ed25519 session keypair and a 12 h parent attestation per launch (mesh secret key signs it), forwards both to the daemon under body.presence. Per-session presence is always on; older brokers that don't recognize session_hello reply unknown_message_type and the daemon quietly drops the per-session WS for that mesh — the regular member-keyed WS still covers all functionality, the only loss is sibling-session visibility on that mesh.
  • latent 1.29.0 bug fixclaudemesh launch referenced claudeSessionId before its const declaration further down the file, hitting the temporal dead zone → ReferenceError silently swallowed by the surrounding catch. Net: the IPC session-token registration has been failing every launch since 1.29.0, falling every session back to user-level scope. Hoisted the declaration up so the registration actually runs.

Sequencing

The broker side ships first and bakes for ~24 h. Older CLIs continue working unchanged (no per-session WS), and the protocol is purely additive on the wire.

Verification (smoke)

In two shells, both cd ~/Desktop/foo:

$ claudemesh launch --name SessionA -y    # shell 1
$ claudemesh launch --name SessionB -y    # shell 2

In a third shell:

$ claudemesh peer list --json --mesh foo \
    | jq '.[] | {n: .displayName, c: .cwd}'
{ "n": "SessionA", "c": "/.../foo" }    ← persistent, not query-induced
{ "n": "SessionB", "c": "/.../foo" }

Inside SessionA, peer list --mesh foo now lists SessionB. Kill SessionB; within ≤30 s the reaper drops it from peer list.

Out of scope (deferred)

  • Attestation auto-refresh — current 12 h TTL is comfortably longer than typical sessions; if a session lives past the TTL and the WS reconnects after expiry, the broker rejects with expired and the SessionBrokerClient quiets. Workaround: claudemesh launch again. Auto-refresh queued for 1.31.0+ alongside HKDF identity.
  • Per-session policy DSL — the per-launch WS could carry per-session capabilities later. Out of scope here.
  • Cross-machine session sync — waits on 2.0.0 HKDF identity.
  • Launch-wizard refactor — bumped to 1.31.0 to keep this release scoped to presence.

1.29.0 (2026-05-04) — per-session IPC tokens + auto-scoping

Sprint A Phase 2. Every claudemesh launch-spawned session gets a unique 32-byte cryptographic token that the daemon resolves on every IPC call to identify which session is talking to it. CLI invocations from inside that session auto-scope to its workspace instead of aggregating across every joined mesh.

What landed

  • services/session/token.ts — mint random 32-byte token, write to <tmpdir>/session-token (mode 0o600). Reader pulls from CLAUDEMESH_IPC_TOKEN_FILE env (path, not value, to keep the secret off ps eww). Optional CLAUDEMESH_IPC_TOKEN direct-value escape hatch for tests.
  • daemon/session-registry.ts — in-memory Map<token, SessionInfo> keyed by token, secondary index by sessionId. 30 s reaper drops entries whose pid is dead; 24 h hard TTL ceiling guards forgotten sessions.
  • IPC routesPOST /v1/sessions/register, DELETE /v1/sessions/:token, GET /v1/sessions/me, GET /v1/sessions.
  • IPC auth middleware — parses Authorization: ClaudeMesh-Session <hex> and attaches the resolved SessionInfo to request context. Layered on top of the existing local-token auth (used for TCP loopback). Backward-compatible: tokenless callers behave exactly as before.
  • services/session/resolve.ts — CLI-side helper that asks the daemon GET /v1/sessions/me once per process and caches the result. Used by verbs that iterate meshes client-side.
  • launch.ts — mints a token, registers it with the daemon, sets CLAUDEMESH_IPC_TOKEN_FILE on the spawned claude env. Token file lives in the same tmpdir as the session config; gets shredded on cleanup. The daemon's reaper handles dead sessions.
  • peers.ts — selection precedence is now --mesh flag → session token's mesh → all joined meshes.

Server-side scoping

Every read route that takes ?mesh=<slug> (peers, state, memory, skills) now uses a meshFromCtx() helper: explicit query/body wins, session default fills in when missing. Write routes (set state, remember, deregister, profile-update) follow the same pattern. Pass --mesh to override.

Verified end-to-end

Setup peer list returns
no token 3 meshes' peers (aggregate, unchanged)
token registered for prueba1 4 peers, all mesh: prueba1

Out of scope (deferred)

  • SQLite persistence for the registry — restart loses it; the reaper (or callers re-registering) covers most cases.
  • SO_PEERCRED-strict pid binding — needs a tiny native binding.
  • Per-session policy DSL.
  • Cross-machine session sync (waiting on 2.0.0 HKDF identity).

1.28.0 (2026-05-04) — bridge tier deletion + daemon-policy flags

First Sprint A drop on the way to v2 thin-client. Two structural changes:

Bridge tier deletion

  • services/bridge/{client,server,protocol}.ts removed (~600 LoC). These were the per-mesh push-pipe sockets that the legacy MCP shim used to hold open; the 1.24.0 shim rewrite stopped opening them but the orphaned client kept being called as a "warm path" tier between daemon and cold. tryBridge() always returned null in production for the last seven releases — pure dead code.
  • Each verb now has two paths only: daemon (with auto-spawn)cold WS. Same pattern shipped in 1.27.3, simpler to follow.
  • commands/{peers,send,broker-actions}.ts — bridge-tier blocks removed; orphaned unambiguousMesh helper removed from broker-actions.

--no-daemon and --strict flags

New per-process daemon policy:

Flag Behavior
(default) probe → auto-spawn → retry → cold fallback
--strict probe → auto-spawn → retry → error if all fail. No cold fallback.
--no-daemon skip daemon entirely → straight to cold path. For sandboxed CI / scripts that don't want a daemon.

Env equivalents: CLAUDEMESH_STRICT_DAEMON=1, CLAUDEMESH_NO_DAEMON=1. Flag wins over env. --no-daemon and --strict are mutually exclusive (--no-daemon wins if both passed).

Strict-mode enforcement lives at withMesh (the cold-path entry point) so a single chokepoint covers every verb. Under --strict, the lifecycle's misleading "using cold path" warning is suppressed so the user sees one clean error instead of a confusing two-step.

What's not in this release (planned for the rest of Sprint A)

  • 1.29.0: per-session IPC tokens + auto-scoping
  • 1.30.0: launch wizard refactor
  • 1.31.0: setup wizard refactor
  • 1.32.0: full mesh→workspace public-surface rename
  • 2.0.0 (separate sprint): HKDF cross-machine identity (security-reviewed)

1.27.3 (2026-05-04) — self-healing daemon lifecycle

The CLI now auto-recovers from a dead daemon on every invocation instead of silently mis-routing through a stale socket.

What changed

  • New services/daemon/lifecycle.ts — single helper that probes the IPC socket via /v1/version (instead of trusting existsSync), cleans up stale daemon.sock / daemon.pid files, and auto-spawns a detached claudemesh daemon up under a file-lock when the daemon is missing.
  • Polls for socket liveness up to a budget (3 s for ad-hoc verbs, 10 s for claudemesh launch) before falling through.
  • Recently-failed marker (~/.claudemesh/daemon/.spawn-failure, 30 s TTL) prevents thundering-herd retries when the daemon crash-loops at startup.
  • Spawn-lock (~/.claudemesh/daemon/.spawn.lock) ensures concurrent CLI invocations share one spawn attempt instead of racing.
  • Per-process result cache — a script doing 50 sends pays the spawn cost at most once, not 50 times.
  • Recursion guard via CLAUDEMESH_INTERNAL_NO_AUTOSPAWN=1 env (set on the spawned daemon's env) so nested CLI calls inside the daemon process don't re-trigger spawn.

User-visible behavior

  • peer list, send, state get, etc. now restart the daemon automatically when invoked while the daemon is down.
  • One-line stderr info on auto-restart: [claudemesh] info daemon restarted automatically (took 615ms).
  • Cold-path fallback fires only when auto-spawn fails or is suppressed by the recently-failed marker; in those cases a warn line points at the daemon log.

Bug fixed

claudemesh launch's ensureDaemonRunning previously checked only existsSync(SOCK_FILE) and returned early on a stale socket left by a crashed daemon — silently breaking new sessions. Now delegates to the lifecycle helper which probes the socket and recovers.

What's not in this patch

  • --strict and --no-daemon flags (deferred to D in 1.28.0).
  • Lazy-loading of cold-path code (deferred to 1.28.0).
  • Per-session IPC tokens (deferred to 1.28.0 alongside D's thin-client conversion).

1.27.2 (2026-05-04) — skill: full-flag launch templates

Documentation-only ship. skills/claudemesh/SKILL.md gains a canonical "fully-populated spawn" recipe under "Wizard-free spawn templates" — every flag set explicitly, with a per-position annotation table — so agents and humans copy-paste a known-good kitchen-sink command instead of stitching one together from the flag table.

Also corrects two pre-existing inaccuracies:

  • --system-prompt was documented as forwarding to claude --append-system-prompt. It actually forwards to claude --system-prompt (overrides the default; pass a string, not a path).
  • -q was listed as a synonym for --quiet. The argv parser treats short flags (-X) and long flags (--xyz) as separate keys; only --quiet is wired. -q is currently a no-op.

Carries a note that all twelve launch flags are end-to-end wired only as of claudemesh-cli@1.27.1.

1.27.1 (2026-05-04) — wire missing launch flags

Fixes a wiring bug in apps/cli/src/entrypoints/cli.ts where six flags declared on LaunchFlags were silently dropped on the way to runLaunch. They were honored inside runLaunch if they ever arrived, but the four runLaunch({...}) call sites in the CLI entrypoint each forwarded a hardcoded 5-key subset (mesh, name, join, yes, resume).

Now forwarded at every entry point (bare command, bare invite URL, launch/connect, workspace launch):

  • --role <r> — sets session role; previously only settable via wizard.
  • --groups "frontend:lead,reviewers" — comma-separated groups string.
  • --message-mode push|inbox|off — message delivery mode.
  • --system-prompt <text> — passes through to claude.
  • --continue — passes through to claude to continue last session.
  • --quiet — actually suppresses the wizard and banner now. Previously it was a complete no-op flag at the CLI layer.

No internal logic changed; the launch internals already read these. This is a pure plumbing fix.

1.27.0 (2026-05-04) — state + memory through the daemon, workspace alias

Two more verb families now route through the local daemon's IPC for the warm path: state get/set/list and remember/recall/forget. Same pattern as 1.25.0 for peers/skills — try the socket first (~1 ms warm), fall back to the cold WS path when the daemon isn't running.

What changed

  • claudemesh state get|set|list route through /v1/state when the daemon socket is present. --mesh <slug> forwards as a query/body field. Single-mesh daemons auto-pick; multi-mesh daemons require --mesh for state set.
  • claudemesh remember, claudemesh recall, claudemesh forget (and claudemesh memory <sub>) route through /v1/memory. Aggregates across attached meshes for recall; requires --mesh for remember/forget when ambiguous.
  • New claudemesh workspace <verb> alias surface — early teaser for the 1.28.0 mesh→workspace public rename. Mirrors list, info, create, join, delete, rename, share, launch, overview. No-arg claudemesh workspace falls through to launch (same as bare claudemesh).

IPC surface

  • GET /v1/state — list (?mesh=<slug> filter) or single key lookup (?key=<k>&mesh=<slug>). Returns 404 with { error: "state_not_found" } when missing.
  • POST /v1/state{ key, value, mesh? }. 400 + attached list when multi-mesh and no mesh field.
  • GET /v1/memory?q=<query>&mesh=<slug> — recall. Aggregates across meshes, each match tagged with its mesh field.
  • POST /v1/memory{ content, tags?, mesh? }. Returns { id, mesh }.
  • DELETE /v1/memory/:id?mesh=<slug> — forget.
  • ipc_features gains state and memory keys.

Why this matters

State and memory were the last verbs that opened a fresh broker WS on every invocation. Now they reuse the daemon's existing connection — the warm-path latency cliff (~150 ms cold WS handshake → ~1 ms IPC) extends to two more flows agents poll heavily.

The workspace alias is cosmetic but lays the groundwork for 1.28.0's documented rename without breaking anyone's muscle memory.

1.26.0 (2026-05-04) — multi-mesh daemon

The daemon now attaches to all joined meshes simultaneously by default. Ambient mode (raw claude after claudemesh install) finally delivers what v2.0.0 promised: one daemon process, one PID per user, all your meshes available concurrently with no manual switching.

What changed

  • claudemesh daemon up (no --mesh flag) attaches to every joined mesh. One DaemonBrokerClient per mesh, all in one process. Pass --mesh <slug> to scope to a single mesh (legacy mode).
  • daemon_started log line now reports meshes: [...] (array) instead of mesh: <slug> (single).
  • Outbox dispatch picks the broker via the mesh column added in 1.25.0. Legacy rows (mesh=NULL) fall back to the only broker if there's exactly one; otherwise mark dead with a clear error.

IPC surface

  • GET /v1/peers aggregates across all attached meshes; each peer record gains a mesh field. ?mesh=<slug> narrows server-side.
  • GET /v1/skills aggregates similarly. GET /v1/skills/:name walks attached meshes and returns the first match (or ?mesh=<slug> to scope).
  • POST /v1/send requires mesh field when the daemon is attached to multiple meshes; auto-picks the only one in single-mesh mode. Returns 400 with the attached mesh list if ambiguous.
  • POST /v1/profile accepts optional mesh field — without it, applies the update to every attached mesh (presence stays consistent across meshes by default).

CLI integration

  • claudemesh send --mesh <slug> forwards the mesh in the daemon request body. The CLI's expectedMesh argument was previously informational; now it's authoritative for routing.
  • claudemesh peer list already aggregates because the IPC endpoint does — no change needed in the verb.
  • Verified end-to-end: claudemesh send --mesh A and claudemesh send --mesh B from the same CLI invocation both reach outbox.status=done with broker-issued IDs, dispatched to the correct broker per row.

What this unlocks

Ambient mode for users with N meshes. Run claudemesh install once, then claude from anywhere — channel push, slash commands, and resources flow through the daemon for every joined mesh simultaneously. No more "which mesh is the daemon attached to?" mental overhead.

1.25.0 (2026-05-04) — Sprint 4 outbound routing + ambient mode

Daemon outbound routing (Sprint 4)

The v0.9.0 daemon shipped outbox infrastructure but its drain worker was a placeholder — every queued send went out as a broadcast (*). That's now fixed. Outbound resolution and crypto_box encryption happen at IPC accept time, then the drain worker just forwards the already-encrypted ciphertext to the broker.

  • Outbox schema additions (additive, NULL allowed for legacy rows): mesh, target_spec, nonce, ciphertext, priority. Existing v0.9.0 rows keep draining via the broadcast fallback.
  • IPC /v1/send resolves the user-friendly to (display name, hex prefix, full pubkey, @group, *, #topicId) into a broker-format target_spec and encrypts the plaintext using crypto_box for DMs (against recipient pubkey + sender session secret) or base64 for broadcast / topic / group targets.
  • Drain worker reads target_spec, nonce, ciphertext, priority from the row and dispatches as-is. No per-row resolution at drain time means peer-presence flicker doesn't affect in-flight sends.
  • Pubkey prefix matching: 16+ char hex prefix matches against peer.pubkey and peer.memberPubkey of connected peers. Ambiguous prefixes return 502 with a clear error.

Smoke test verified end-to-end: claudemesh send --self <prefix> "..." through daemon resolves, encrypts, and delivers. Outbox reaches status=done with broker-issued broker_message_id.

CLI thin-client routing extensions

claudemesh peer list and claudemesh skill list/get now route through the daemon when its socket is present, mirroring the trySendViaDaemon pattern from send.ts. Same fall-back chain: daemon → bridge → cold path.

New helpers in services/bridge/daemon-route.ts:

  • tryListPeersViaDaemon()
  • tryListSkillsViaDaemon()
  • tryGetSkillViaDaemon(name)

Ambient mode

After claudemesh install (which now installs and starts the daemon service), raw claude Just Works for the daemon's attached mesh. No claudemesh launch ceremony needed for the common case. Channel push, slash commands, and resources flow through the daemon-backed MCP shim.

claudemesh launch remains the override path: explicit mesh selection, fresh display name, headless modes, system-prompt injection, or multi-mesh users who want to spawn into a non-default mesh.

Roadmap spec

.artifacts/specs/2026-05-04-v2-roadmap-completion.md documents exactly what's done vs. what remains for the full v2.0.0 endpoint: multi-mesh daemon (1.26.0), full CLI-to-thin-client conversion (1.27.0), mesh→workspace rename (1.28.0), HKDF identity (2.0.0).

1.24.0 (2026-05-03) — daemon required + thin MCP shim

The architectural convergence v0.9.0 was building toward.

Daemon promoted from optional to required (for in-Claude-Code use)

The CLI itself (claudemesh send, peer list, inbox, vault, watch, webhook, etc.) keeps working without a daemon. But the MCP server — which provides Claude Code's mid-turn channel push, slash commands, and resource browser — now requires the daemon. There is no fallback.

  • claudemesh install auto-installs and starts the daemon service (launchd / systemd-user) for the user's primary mesh. Pass --no-service to opt out.
  • claudemesh launch ensures the daemon is running before spawning Claude Code; spawns it foreground if absent.
  • The MCP shim probes ~/.claudemesh/daemon/daemon.sock at boot. If missing after a 2s grace window, it bails with actionable instructions ("run claudemesh daemon up --mesh <slug>").

MCP server: 979 → ~300 LoC of push-pipe code

apps/cli/src/mcp/server.ts is now a thin daemon-SSE translator. It no longer holds a broker WebSocket, decrypts messages, manages mesh state, or runs reconnection logic. All of that is the daemon's job.

  • Subscribes to daemon /v1/events SSE; translates each message event into a notifications/claude/channel emit.
  • Sources mesh-published skills via daemon /v1/skills IPC for ListPrompts / GetPrompt / ListResources / ReadResource.
  • ListTools returns [] (the CLI is the API, taught via the bundled skill).
  • The mesh-service proxy mode (claudemesh-cli --service <name>, the sub-MCP-server for proxying a deployed mesh-MCP service) is unchanged — separate code path, different lifecycle.

Bundle size: MCP entry dropped from 154KB → 104KB (gzipped 34KB → 19KB).

Daemon SSE event payload extended

message events on /v1/events now include plaintext-decrypted body, sender member pubkey, priority, and subtype — everything the MCP shim needs to render a complete channel notification without going back to the broker.

Daemon IPC: GET /v1/skills (list) and GET /v1/skills/:name (get)

The daemon exposes mesh-published skills over IPC so the MCP shim can surface them as MCP prompts/resources without holding its own broker WS. Same wire format as before from Claude Code's perspective.

Why this is the right architecture

MCP and the daemon are no longer independent broker clients with duplicated WS, decrypt, and dedupe logic. The daemon owns the broker relationship; MCP is a Claude-Code-specific UX adapter that reads from the daemon. Industry-normal shape (Tailscale, Slack, Ollama, Docker) where the long-lived runtime is required and the per-app integrations attach to it.

1.23.0 (2026-05-03) — close the CLI surface, prune dead MCP stubs

Three previously-MCP-only write verbs land on the CLI, closing every functional gap between the (defunct since 1.5.0) MCP tool registry and the CLI:

  • claudemesh vault set <key> <value> — encrypts client-side via crypto_secretbox_easy with a fresh symmetric key, then seals the key to the member's own pubkey via crypto_box_seal (same shape as the file-share crypto). Flags: --type env|file, --mount <path>, --description <text>. Pairs with the existing vault list/delete.
  • claudemesh watch add <url> — registers a URL change watcher. Flags: --label, --interval <sec>, --mode, --extract <css>, --notify-on changed|always. Pairs with watch list/remove.
  • claudemesh webhook create <name> — issues a fresh inbound webhook; prints url + one-shot secret. Pairs with webhook list/delete.

Cleanup: removed 22 dead stub files under apps/cli/src/mcp/tools/*, the unused router.ts, middleware/*, and handlers/* directories (~120 LoC). The MCP server in 1.5.0+ has been a tool-less push-pipe; these stubs were leftover scaffolding that never wired into the tools/list response. The legitimate MCP surfaces stay untouched:

  • <channel source="claudemesh"> push pipe (the irreducible reason MCP exists at all — no other Claude Code surface can inject events mid-turn).
  • Mesh skills exposed as MCP prompts (slash commands) and resources (skill://claudemesh/<name>).
  • Mesh-deployed MCP services proxied via the sub-process tool surface (separate code path under server.ts:855+).

1.22.1 (2026-05-03) — daemon docs + help

  • Root claudemesh --help now lists the daemon subcommand suite under its own section (was missing in 1.22.0).
  • claudemesh daemon (no subcommand) now prints a usage block instead of silently launching the daemon. daemon help|--help|-h work too.
  • Bundled SKILL.md gained a "Daemon path (v0.9.0, opt-in, fastest)" section explaining the runtime, lifecycle commands, and how it relates to claudemesh install (independent — not auto-started).

1.22.0 (2026-05-03) — daemon v0.9.0

New: claudemesh daemon — long-lived peer mesh runtime

Persistent local process that holds the broker WS, durable outbox/inbox in SQLite, IPC over UDS (+ optional loopback TCP with bearer token), and SSE event stream. Surrogates wire-up; claudemesh send and friends route through the daemon when its socket is present, falling back to the existing bridge / cold paths otherwise.

Subcommands:

  • daemon up|start [--mesh <slug>] [--name ...] [--no-tcp] [--public-health]
  • daemon status [--json], daemon down|stop, daemon version
  • daemon outbox list [--failed|--pending|--inflight|--done]
  • daemon outbox requeue <id> [--new-client-id <id>]
  • daemon accept-host (per-host fingerprint pin)
  • daemon install-service --mesh <slug> (macOS launchd / Linux systemd-user)
  • daemon uninstall-service

Idempotency end-to-end:

  • Caller-stable client_message_id + canonical request_fingerprint (sha256 of envelope_version || dest_kind || dest_ref || reply_to || priority || canonical_meta_json || body_hash) attach on every send.
  • Broker persists both on mesh.message_queue (migration 0028, additive
    • nullable) and echoes them on push, so receiving daemons dedupe their inbox by client_message_id.
  • §4.5.1 IPC duplicate-lookup table (11 cases × no-row / 5 statuses × match/mismatch) covered by 15 unit tests.

Crash recovery:

  • Outbox row transitions: pendinginflightdone / dead / aborted. BEGIN IMMEDIATE serializes daemon-local writes; the drain worker is wakeable via promise-replacement and backs off failed sends.
  • Decrypt path tries session secret key, then member secret key, then base64 fallback, so legacy unencrypted pushes still inbox cleanly.

Sprint 7 (broker-side dedupe enforcement: partial unique index + mesh.client_message_dedupe atomic-accept table) is intentionally deferred — see .artifacts/shipped/2026-05-03-daemon-spec-broker- hardening-followups.md.

1.0.0-alpha.0 (2026-04-13)

Architecture

  • Complete folder restructure: entrypoints/, cli/, commands/, services/ (17 feature-folders with facade pattern), ui/, mcp/, constants/, types/, utils/, locales/, templates/
  • 212 source files, 10,900 lines
  • ESM-only, Bun bundler, TypeScript strict mode

New CLI commands

  • claudemesh register — account creation via browser handoff
  • claudemesh login — device-code OAuth
  • claudemesh logout — revoke session + clear credentials
  • claudemesh whoami — identity check with --json support
  • claudemesh new <name> — create mesh from CLI (was dashboard-only)
  • claudemesh invite [email] — generate invite from CLI (was dashboard-only)

Ported from v1 (full feature parity)

  • All 79 MCP tools
  • All 85 WS message types (broker protocol unchanged)
  • Welcome wizard, launch flow, install/uninstall
  • Ed25519 + NaCl crypto (keypairs, crypto_box DMs, file encryption)
  • Reconnect with exponential backoff
  • Status priority engine, scheduled messages, URL watch
  • Doctor checks, Telegram bridge connect wizard

Security hardening (25 bugs fixed across 4 reviews)

  • execFile instead of exec for browser open (command injection fix)
  • ReDoS-safe pattern matching in peer file sharing
  • Atomic config writes via temp file + rename
  • Auth token stored with openSync(mode: 0o600) — no permission race
  • Decryption oracle collapsed to generic error in get_file
  • Download size limit (100MB) on file retrieval
  • Path traversal protection with realpathSync for symlink escapes
  • Callback listener double-resolve guard
  • Push buffer 1MB per-message truncation
  • makeReqId uses crypto.randomBytes instead of Math.random
  • Connect guard prevents double-connect race

Breaking changes from v0.10.x

  • Flat command namespace (no launch subcommand, no advanced prefix)
  • New config shape (same data, cleaner layout)
  • New --json output format with schema_version: "1.0"
  • New exit codes (see constants/exit-codes.ts)