12 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
ee585a8370 fix(cli): v0.5.7 — event loop keepalive for stdout flush
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
Release / Publish multi-arch images (push) Has been cancelled
Node.js stdout to a pipe is buffered. Without periodic event loop
activity, WS callback → server.notification() → stdout.write() may
not flush until the next I/O event. A 1s setInterval (NOT unref'd)
keeps the event loop ticking so notifications flush immediately.

This is why claude-intercom worked: its 1s HTTP poll kept the event
loop active as a side effect. Claudemesh's passive WS listener let
the event loop settle, causing stdout to buffer indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:48:41 +01:00
Alejandro Gutiérrez
1f078bf0c8 fix(web): --no-turbopack for prod build (payload CSS)
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-06 17:32:24 +01:00
Alejandro Gutiérrez
2372032a68 fix(cli): v0.5.6 — fix ping_mesh self-send + add diagnostics
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:03 +01:00
Alejandro Gutiérrez
a70c5fd124 feat(cli): v0.5.5 — ping_mesh diagnostic tool
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
Release / Publish multi-arch images (push) Has been cancelled
Sends test messages to self through the full pipeline per priority
and measures round-trip timing. Reports send→ack and send→receive
latency. Detects broker priority gating (status=working holds next/low).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:27:00 +01:00
Alejandro Gutiérrez
5c62d287cf fix(cli): v0.5.4 — revert to event-driven push, add Claude Code integration spec
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
Release / Publish multi-arch images (push) Has been cancelled
Revert poll-based drain (v0.5.2 overcorrection). Claude Code source
confirms notifications are processed event-driven via React
useEffect, not polled. The WS onPush → server.notification() path
is correct.

Added section 13 to SPEC.md documenting the full Claude Code
notification pipeline, feature gates, priority gating, and common
push delivery issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:04:05 +01:00
Alejandro Gutiérrez
9ae378c2e3 fix(cli): v0.5.3 — add push delivery debug logging
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:49:49 +01:00
Alejandro Gutiérrez
7381738f0b fix(web): disable turbopack for prod build (payload CSS compat)
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 16:46:28 +01:00
Alejandro Gutiérrez
8c6b0c0e07 fix(cli): v0.5.2 — poll-based push delivery (1s interval)
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
Release / Publish multi-arch images (push) Has been cancelled
Replace WS onPush→notification with timer-based buffer drain.
The old claude-intercom used 1s polling and worked reliably.
WS async callbacks may not flush stdio properly for MCP
notifications. Polling on a timer ensures consistent delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:33:26 +01:00
Alejandro Gutiérrez
ec9626503c fix(web): force-dynamic on payload admin page (build CSS 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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:16:21 +01:00
Alejandro Gutiérrez
820ec085b2 feat(cli): v0.5.1 — message modes (push/inbox/off)
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
Release / Publish multi-arch images (push) Has been cancelled
--inbox: count-only notifications, no content in context
--no-messages: tools only, zero prompt injection risk
Default: push (real-time, current behavior)

Wizard shows mode picker when no flag provided.
MCP instructions tell Claude its current mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:53:41 +01:00
Alejandro Gutiérrez
9e6f6d7bc9 docs: add message modes + shared MCPs spec
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
Message modes: push/inbox/off for controlling prompt injection risk.
Shared MCPs: mesh-level MCP servers proxied through the broker —
install once, every peer has access. Full architecture, DB schema,
WS protocol, credential isolation, resource limits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:52:43 +01:00
Alejandro Gutiérrez
7194e7d28e chore: regenerate lockfile from scratch
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 15:47:26 +01:00
11 changed files with 373 additions and 86 deletions

214
SPEC.md
View File

@@ -722,7 +722,196 @@ mesh.stream (
--- ---
## 11. Encryption ## 11. Message Modes
Peers choose how messages reach them. Tools (state, memory, files, etc.) always work regardless of mode.
```bash
claudemesh launch --name Alice # push (default)
claudemesh launch --name Alice --inbox # held until check_messages
claudemesh launch --name Alice --no-messages # tools only, silent
```
| Mode | Messages | Prompt injection risk | Use case |
|------|----------|----------------------|----------|
| `push` | Real-time into context | Yes | Active collaboration, role-play |
| `inbox` | Count notification only | Minimal | Focused work, check when ready |
| `off` | None (check_messages manual) | Zero | Data analysis, shared infra only |
Wizard shows the choice when neither `--inbox` nor `--no-messages` is passed.
---
## 12. Shared MCPs
MCP servers installed once at the mesh level, available to all peers. The broker runs MCP processes and proxies tool calls.
### Why
Today: each peer loads MCPs from `~/.claude.json`. Four peers = four instances of the GitHub MCP, each with its own credentials, its own connection, its own state. Wasteful and inconsistent.
Mesh MCPs: the broker runs the MCP server once. Peers call tools through claudemesh. One install, every peer has access. Zero local config.
### Architecture
```
Peer A ──┐ ┌── GitHub MCP (one process)
Peer B ──┤── Broker (MCP proxy) ──┤── Postgres MCP (one process)
Peer C ──┘ └── Slack MCP (one process)
```
### Admin installs MCPs
```bash
# From a peer with admin role, or the CLI
claudemesh mcp-add --mesh dev-team github -- npx @modelcontextprotocol/server-github
claudemesh mcp-add --mesh dev-team postgres -- npx @modelcontextprotocol/server-postgres
claudemesh mcp-remove --mesh dev-team github
claudemesh mcp-list --mesh dev-team
```
Or via MCP tools (admin peers only):
```
mesh_mcp_add(name: "github", command: "npx", args: ["@modelcontextprotocol/server-github"], env: {"GITHUB_TOKEN": "..."})
mesh_mcp_remove(name: "github")
```
### Peer uses shared MCPs
```
list_mesh_mcps() → ["github (12 tools)", "postgres (8 tools)", "slack (6 tools)"]
mesh_tool(mcp: "github", tool: "search_issues", args: { query: "auth bug" })
```
Two tools. `list_mesh_mcps` for discovery, `mesh_tool` for execution. Claude reads the tool list, picks the right one, calls it.
### Broker internals
```sql
mesh.mcp_server (
id text PK,
mesh_id text FK,
name text NOT NULL,
command text NOT NULL,
args text[] DEFAULT '{}',
env jsonb DEFAULT '{}',
status text DEFAULT 'stopped',
installed_by text,
installed_at timestamp DEFAULT NOW(),
UNIQUE(mesh_id, name)
)
```
The broker:
1. Spawns each MCP as a child process with stdio transport
2. Keeps a JSON-RPC connection to each
3. On `list_mesh_mcps`: queries each MCP's `tools/list`
4. On `mesh_tool`: forwards the `tools/call` to the right MCP, returns the result
5. Restarts crashed MCPs automatically (like the WS reconnect logic)
6. Stops MCPs when the mesh has zero connected peers (resource savings)
### Credential isolation
- Env vars stored encrypted in the DB (mesh.mcp_server.env)
- Only the broker process reads them — never sent to peers
- Peers see tool names and descriptions, never credentials
- Admin can rotate credentials via `mesh_mcp_update`
### Resource limits
- Max N MCP servers per mesh (configurable, default 10)
- Max M concurrent tool calls per peer (default 5)
- Tool call timeout (default 30s)
- MCP process memory limit via Docker/cgroup
### WS protocol
| Type | Fields | Description |
|------|--------|-------------|
| `list_mesh_mcps` | — | List shared MCPs and their tools |
| `mesh_tool` | mcp, tool, args | Call a tool on a shared MCP |
| `mesh_mcp_add` | name, command, args?, env? | Install an MCP (admin) |
| `mesh_mcp_remove` | name | Uninstall an MCP (admin) |
| `mesh_mcp_list_result` | mcps[] | Response with MCP names + tool lists |
| `mesh_tool_result` | result | Tool call response |
### MCP tools for shared MCPs
| Tool | Description |
|------|-------------|
| `list_mesh_mcps()` | List shared MCPs with their tool summaries |
| `mesh_tool(mcp, tool, args)` | Execute a tool on a shared MCP |
| `mesh_mcp_add(name, command, args?, env?)` | Install a shared MCP (admin) |
| `mesh_mcp_remove(name)` | Uninstall a shared MCP (admin) |
### What this enables
- **Team onboarding**: new peer joins mesh, instantly has all team tools
- **Central credentials**: GitHub token, DB password — stored once on the broker
- **Tool standardization**: everyone uses the same MCP version, same config
- **Ephemeral peers**: a peer spun up for 5 minutes gets full tool access without any local setup
- **AI self-provisioning** (future): a peer calls `mesh_mcp_add` to install a new tool it needs
---
## 13. Claude Code Integration — How Push Delivery Works
Understanding how Claude Code processes channel notifications is critical for claudemesh reliability.
### The notification pipeline
```
MCP server (claudemesh-cli)
└─ server.notification("notifications/claude/channel", { content, meta })
└─ writes JSON-RPC to stdout
└─ Claude Code reads from MCP process stdout
└─ setNotificationHandler fires
└─ enqueue({ mode: "prompt", value: wrappedContent, origin: { kind: "channel" } })
└─ React useSyncExternalStore triggers re-render
└─ useQueueProcessor effect fires
└─ processQueueIfReady() → executeInput()
└─ Claude sees ← claudemesh: ...
```
### Key requirements (from Claude Code source)
1. **Feature gate**: `feature('KAIROS') || feature('KAIROS_CHANNELS')` must be true. `KAIROS_CHANNELS` is external (GrowthBook). `--dangerously-load-development-channels` sets `entry.dev = true` which bypasses the allowlist check but still requires the feature gate.
2. **OAuth auth required**: Channel notifications require `claude.ai` authentication (OAuth tokens). API key users are blocked. This means `claude login --for-claude-ai` must have been run.
3. **Server name must match**: The MCP server's declared name (`new Server({ name: "claudemesh" })`) must match the channel entry from `--dangerously-load-development-channels server:claudemesh`.
4. **Meta keys**: Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. No hyphens. All values must be strings.
5. **Capability declaration**: Server must declare `experimental: { "claude/channel": {} }` in capabilities.
6. **Queue processing is event-driven**: `enqueue()` triggers a React store update → `useEffect` fires → processes immediately. No polling needed on the Claude Code side. The 1s poll timer in claudemesh is for draining the WS push buffer into notifications — Claude Code handles the rest instantly.
### Priority gating on the broker
The broker holds `"next"` and `"low"` priority messages when the peer's status is `"working"`. Only `"now"` messages deliver immediately regardless of status. This is by design — but can cause perceived "push not working" when the hook reports `working` status.
```
Status: idle → delivers: now, next, low
Status: working → delivers: now only
Status: dnd → delivers: now only
```
If a peer appears to not receive messages, check their status in `list_peers`. A peer stuck in `"working"` (e.g., stale hook) will only receive `"now"` priority messages.
### Common issues
| Symptom | Likely cause |
|---------|-------------|
| Messages never arrive | Session started before CLI update — restart with `claudemesh launch` |
| Messages arrive with 5+ minute delay | Peer status stuck on `"working"``next` messages held until idle |
| `← claudemesh:` never appears in idle session | Feature gate `KAIROS_CHANNELS` not enabled, or not OAuth-authenticated |
| Messages arrive only on `check_messages` | Channel handler not registered — check `--dangerously-load-development-channels` flag |
---
## 14. Encryption
### Direct messages ### Direct messages
@@ -742,7 +931,7 @@ The session keypair generates once on first connect and survives reconnects. Mes
--- ---
## 12. Production hardening (implemented) ## 14. Production hardening (implemented)
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
@@ -757,7 +946,7 @@ The session keypair generates once on first connect and survives reconnects. Mes
--- ---
## 13. CLI commands ## 15. CLI commands
``` ```
claudemesh install Register MCP server + hooks in Claude Code claudemesh install Register MCP server + hooks in Claude Code
@@ -786,7 +975,7 @@ claudemesh mcp Start MCP server (invoked by Claude Code, not users)
--- ---
## 14. Implementation status ## 16. Implementation status
| Phase | Version | Status | What | | Phase | Version | Status | What |
|-------|---------|--------|------| |-------|---------|--------|------|
@@ -807,17 +996,20 @@ claudemesh mcp Start MCP server (invoked by Claude Code, not users)
| **Files** | **v0.4.0** | **Done** | MinIO-backed file sharing + message attachments | | **Files** | **v0.4.0** | **Done** | MinIO-backed file sharing + message attachments |
| **Multi-target** | **v0.4.0** | **Done** | Array `to` field with deduplication | | **Multi-target** | **v0.4.0** | **Done** | Array `to` field with deduplication |
| **Targeted views** | **v0.4.0** | **Done** | MCP instruction pattern for per-audience messages | | **Targeted views** | **v0.4.0** | **Done** | MCP instruction pattern for per-audience messages |
| Vectors | v0.5.0 | Planned | Qdrant per-mesh collections for semantic search | | **Vectors** | **v0.5.0** | **Done** | Qdrant per-mesh collections for semantic search |
| Graph | v0.5.0 | Planned | Neo4j per-mesh databases for entity relationships | | **Graph** | **v0.5.0** | **Done** | Neo4j per-mesh databases for entity relationships |
| Context sharing | v0.5.0 | Planned | Session understanding exchange between peers | | **Context sharing** | **v0.5.0** | **Done** | Session understanding exchange between peers |
| Tasks | v0.5.0 | Planned | First-class work items with claim/complete | | **Tasks** | **v0.5.0** | **Done** | First-class work items with claim/complete |
| Mesh databases | v0.6.0 | Planned | Per-mesh PostgreSQL schemas for structured data | | **Mesh databases** | **v0.5.0** | **Done** | Per-mesh PostgreSQL schemas for structured data |
| Streams | v0.6.0 | Planned | Real-time pub/sub data channels | | **Streams** | **v0.5.0** | **Done** | Real-time pub/sub data channels |
| **mesh_info** | **v0.5.0** | **Done** | One-call aggregated mesh overview |
| Message modes | v0.5.1 | In progress | push/inbox/off modes for message delivery |
| Shared MCPs | v0.6.0 | Planned | Mesh-level MCP servers, broker as proxy |
| Dashboard | v0.7.0 | Planned | Live peers, state, memory, files, graphs in web UI | | Dashboard | v0.7.0 | Planned | Live peers, state, memory, files, graphs in web UI |
--- ---
## 15. Design principles ## 17. Design principles
1. **The broker is a dumb pipe.** It routes messages, stores state, holds memory. It does not interpret roles, enforce protocols, or run agents. 1. **The broker is a dumb pipe.** It routes messages, stores state, holds memory. It does not interpret roles, enforce protocols, or run agents.

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.5.0", "version": "0.5.7",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -26,6 +26,7 @@ interface LaunchArgs {
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member" groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
joinLink: string | null; joinLink: string | null;
meshSlug: string | null; meshSlug: string | null;
messageMode: "push" | "inbox" | "off" | null;
quiet: boolean; quiet: boolean;
skipPermConfirm: boolean; skipPermConfirm: boolean;
claudeArgs: string[]; claudeArgs: string[];
@@ -38,6 +39,7 @@ function parseArgs(argv: string[]): LaunchArgs {
groups: null, groups: null,
joinLink: null, joinLink: null,
meshSlug: null, meshSlug: null,
messageMode: null,
quiet: false, quiet: false,
skipPermConfirm: false, skipPermConfirm: false,
claudeArgs: [], claudeArgs: [],
@@ -66,6 +68,10 @@ function parseArgs(argv: string[]): LaunchArgs {
result.meshSlug = argv[++i]!; result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) { } else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length); result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--inbox") {
result.messageMode = "inbox";
} else if (arg === "--no-messages") {
result.messageMode = "off";
} else if (arg === "--quiet") { } else if (arg === "--quiet") {
result.quiet = true; result.quiet = true;
} else if (arg === "-y" || arg === "--yes") { } else if (arg === "-y" || arg === "--yes") {
@@ -171,7 +177,7 @@ async function confirmPermissions(): Promise<void> {
// --- Banner --- // --- Banner ---
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[]): void { function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
const useColor = const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
@@ -183,9 +189,15 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
: ""; : "";
const rule = "─".repeat(60); const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags}`)); console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule); console.log(rule);
console.log("Peer messages arrive as <channel> reminders in real-time."); if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files."); console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`)); console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule); console.log(rule);
@@ -263,6 +275,8 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
let role: string | null = args.role; let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : []; let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
if (!args.quiet) { if (!args.quiet) {
if (role === null) { if (role === null) {
const answer = await askLine(" Role (optional): "); const answer = await askLine(" Role (optional): ");
@@ -272,6 +286,18 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
const answer = await askLine(" Groups (comma-separated, optional): "); const answer = await askLine(" Groups (comma-separated, optional): ");
if (answer) parsedGroups = parseGroupsString(answer); if (answer) parsedGroups = parseGroupsString(answer);
} }
if (args.messageMode === null) {
console.log("\n Message mode:");
console.log(" 1) Push (real-time, peers can interrupt your work)");
console.log(" 2) Inbox (held until you check, notification only)");
console.log(" 3) Off (tools only, no messages)");
console.log("");
const answer = await askLine(" Choice [1]: ");
const choice = parseInt(answer || "1", 10);
if (choice === 2) messageMode = "inbox";
else if (choice === 3) messageMode = "off";
else messageMode = "push";
}
if (role || parsedGroups.length) console.log(""); if (role || parsedGroups.length) console.log("");
} }
@@ -293,6 +319,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
meshes: [mesh], meshes: [mesh],
displayName, displayName,
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
}; };
writeFileSync( writeFileSync(
join(tmpDir, "config.json"), join(tmpDir, "config.json"),
@@ -302,7 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
// 5. Banner + permission confirmation. // 5. Banner + permission confirmation.
if (!args.quiet) { if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups); printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
// Auto-permissions confirmation — needed for autonomous peer messaging. // Auto-permissions confirmation — needed for autonomous peer messaging.
if (!args.skipPermConfirm) { if (!args.skipPermConfirm) {
await confirmPermissions(); await confirmPermissions();

View File

@@ -131,6 +131,7 @@ export async function startMcpServer(): Promise<void> {
const myName = config.displayName ?? "unnamed"; const myName = config.displayName ?? "unnamed";
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none"; const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push";
const server = new Server( const server = new Server(
{ name: "claudemesh", version: "0.3.0" }, { name: "claudemesh", version: "0.3.0" },
@@ -236,7 +237,13 @@ Create and claim work items. create_task to propose work, claim_task to take own
- "low": pull-only via check_messages (FYI, non-blocking context) - "low": pull-only via check_messages (FYI, non-blocking context)
## Coordination ## Coordination
Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus.`, Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus.
## Message Mode
Your message mode is "${messageMode}".
- push: messages arrive in real-time as channel notifications. Respond immediately.
- inbox: messages are held. You'll see "[inbox] New message from X" notifications. Call check_messages to read them.
- off: no message notifications. Use check_messages manually to poll.`,
}, },
); );
@@ -438,11 +445,14 @@ Call list_peers at session start to understand who is online, their roles, and w
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0]; const client = allClients()[0];
if (!client) return text("share_file: not connected", true); if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { try {
name: fileName, tags, persistent: true, const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
}); name: fileName, tags, persistent: true,
if (!fileId) return text("share_file: upload failed", true); });
return text(`Shared: ${fileName ?? filePath} (${fileId})`); return text(`Shared: ${fileName ?? filePath} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
}
} }
case "get_file": { case "get_file": {
@@ -700,6 +710,56 @@ Call list_peers at session start to understand who is online, their roles, and w
return text(lines.join("\n")); return text(lines.join("\n"));
} }
case "ping_mesh": {
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
const client = allClients()[0];
if (!client) return text("ping_mesh: not connected", true);
const results: string[] = [];
// Diagnostics: connection state
results.push(`WS status: ${client.status}`);
results.push(`Mesh: ${client.meshSlug}`);
// Check own peer status (explains priority gating)
const peers = await client.listPeers();
const selfPeer = peers.find(p => p.displayName === myName);
results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`);
results.push(`Peers online: ${peers.length}`);
results.push(`Push buffer: ${client.pushHistory.length} buffered`);
// Test send→ack latency per priority (doesn't need round-trip)
for (const prio of toTest) {
const sendTime = Date.now();
// Send to a peer if one exists, otherwise broadcast
const target = peers.find(p => p.displayName !== myName);
const sendResult = await client.send(
target?.pubkey ?? "*",
`__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`,
prio,
);
const ackTime = Date.now();
if (!sendResult.ok) {
results.push(`[${prio}] SEND FAILED: ${sendResult.error}`);
} else {
results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`);
if (prio !== "now" && selfPeer?.status === "working") {
results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`);
}
}
}
// Check if notification pipeline works
results.push("");
results.push("Pipeline check:");
results.push(` onPush handlers: active`);
results.push(` messageMode: ${messageMode}`);
results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`);
return text(results.join("\n"));
}
default: default:
return text(`Unknown tool: ${name}`, true); return text(`Unknown tool: ${name}`, true);
} }
@@ -715,12 +775,33 @@ Call list_peers at session start to understand who is online, their roles, and w
// any mesh's broker connection becomes a <channel source="claudemesh"> // any mesh's broker connection becomes a <channel source="claudemesh">
// system reminder injected into Claude Code's context. // system reminder injected into Claude Code's context.
for (const client of allClients()) { for (const client of allClients()) {
// Event-driven push: WS onPush fires immediately when a message arrives.
// Claude Code's setNotificationHandler → enqueue → React useEffect pipeline
// processes notifications instantly (no polling needed on Claude's side).
// The old poll-based approach was an overcorrection — Claude Code source
// confirms event-driven notification processing.
client.onPush(async (msg) => { client.onPush(async (msg) => {
if (messageMode === "off") return;
const fromPubkey = msg.senderPubkey || ""; const fromPubkey = msg.senderPubkey || "";
// Resolve sender's display name from the cached peer list.
const fromName = fromPubkey const fromName = fromPubkey
? await resolvePeerName(client, fromPubkey) ? await resolvePeerName(client, fromPubkey)
: "unknown"; : "unknown";
if (messageMode === "inbox") {
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[inbox] New message from ${fromName}. Use check_messages to read.`,
meta: { kind: "inbox_notification", from_name: fromName },
},
});
} catch { /* best effort */ }
return;
}
// push mode — full content
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try { try {
await server.notification({ await server.notification({
@@ -739,8 +820,9 @@ Call list_peers at session start to understand who is online, their roles, and w
}, },
}, },
}); });
} catch { process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
/* channel push is best-effort; check_messages is the fallback */ } catch (pushErr) {
process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`);
} }
}); });
@@ -777,7 +859,21 @@ Call list_peers at session start to understand who is online, their roles, and w
}); });
} }
// Event loop keepalive: Node.js stdout to a pipe is buffered. Without
// periodic event loop activity, stdout.write() from WS callbacks may not
// flush until the next I/O event. This 1s interval keeps the event loop
// ticking so channel notifications flush promptly — same pattern that made
// claude-intercom's push delivery reliable (its 1s HTTP poll had this
// effect as a side effect). The interval does nothing except prevent the
// event loop from settling.
const keepalive = setInterval(() => {
// Intentionally empty — the interval itself keeps the event loop active.
// Do NOT call .unref() — that would defeat the purpose.
}, 1_000);
void keepalive; // suppress unused warning
const shutdown = (): void => { const shutdown = (): void => {
clearInterval(keepalive);
stopAll(); stopAll();
process.exit(0); process.exit(0);
}; };

View File

@@ -555,4 +555,21 @@ export const TOOLS: Tool[] = [
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.", "Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
// --- Diagnostics ---
{
name: "ping_mesh",
description:
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
inputSchema: {
type: "object",
properties: {
priorities: {
type: "array",
items: { type: "string", enum: ["now", "next", "low"] },
description: "Priorities to test (default: [\"now\", \"next\"])",
},
},
},
},
]; ];

View File

@@ -38,6 +38,7 @@ export interface Config {
meshes: JoinedMesh[]; meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name` displayName?: string; // per-session override, written by `claudemesh launch --name`
groups?: GroupEntry[]; groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off";
} }
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh"); const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
@@ -53,7 +54,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) { if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] }; return { version: 1, meshes: [] };
} }
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups }; return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`, `Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -526,8 +526,11 @@ export class BrokerClient {
body: data, body: data,
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
const body = await res.json() as { ok?: boolean; fileId?: string }; const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
return body.fileId ?? null; if (!res.ok || !body.fileId) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body.fileId;
} }
// --- Vectors --- // --- Vectors ---

View File

@@ -25,6 +25,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# TURBOPACK=0 forces webpack for production build — Payload CMS's
# richtext-lexical CSS imports fail under Turbopack.
ENV TURBOPACK=0
RUN npx turbo run build --filter=web... RUN npx turbo run build --filter=web...
# Stage 2: runtime — standalone output only # Stage 2: runtime — standalone output only

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build --no-turbopack",
"clean": "git clean -xdf .cache .next .turbo node_modules", "clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "next dev", "dev": "next dev",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",

View File

@@ -4,6 +4,8 @@ import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap"; import { importMap } from "../importMap";
import config from "@payload-config"; import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ segments: string[] }> }; type Args = { params: Promise<{ segments: string[] }> };
export const generateMetadata = ({ params }: Args) => export const generateMetadata = ({ params }: Args) =>

60
pnpm-lock.yaml generated
View File

@@ -13719,10 +13719,6 @@ packages:
undici-types@7.8.0: undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
undici@6.21.3:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
engines: {node: '>=18.17'}
undici@6.24.1: undici@6.24.1:
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
@@ -16732,7 +16728,7 @@ snapshots:
structured-headers: 0.4.1 structured-headers: 0.4.1
tar: 7.5.2 tar: 7.5.2
terminal-link: 2.1.1 terminal-link: 2.1.1
undici: 6.21.3 undici: 6.24.1
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
ws: 8.20.0 ws: 8.20.0
optionalDependencies: optionalDependencies:
@@ -20970,7 +20966,7 @@ snapshots:
'@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13) '@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13)
unplugin: 1.0.1 unplugin: 1.0.1
uuid: 9.0.1 uuid: 9.0.1
webpack: 5.100.2 webpack: 5.100.2(esbuild@0.25.0)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
- supports-color - supports-color
@@ -26959,7 +26955,7 @@ snapshots:
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.2.2 '@next/swc-darwin-arm64': 16.2.2
'@next/swc-darwin-x64': 16.2.2 '@next/swc-darwin-x64': 16.2.2
@@ -29481,12 +29477,6 @@ snapshots:
react: 19.2.3 react: 19.2.3
optionalDependencies: optionalDependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
optional: true
styled-jsx@5.1.6(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
styleq@0.1.3: styleq@0.1.3:
optional: true optional: true
@@ -29644,15 +29634,6 @@ snapshots:
optionalDependencies: optionalDependencies:
esbuild: 0.25.0 esbuild: 0.25.0
terser-webpack-plugin@5.3.14(webpack@5.100.2):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.43.1
webpack: 5.100.2
terser@5.43.1: terser@5.43.1:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.10 '@jridgewell/source-map': 0.3.10
@@ -29950,9 +29931,6 @@ snapshots:
undici-types@7.8.0: {} undici-types@7.8.0: {}
undici@6.21.3:
optional: true
undici@6.24.1: {} undici@6.24.1: {}
undici@7.24.4: {} undici@7.24.4: {}
@@ -30358,38 +30336,6 @@ snapshots:
webpack-virtual-modules@0.5.0: {} webpack-virtual-modules@0.5.0: {}
webpack@5.100.2:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.16.0
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.25.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.2
tapable: 2.2.2
terser-webpack-plugin: 5.3.14(webpack@5.100.2)
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.100.2(esbuild@0.25.0): webpack@5.100.2(esbuild@0.25.0):
dependencies: dependencies:
'@types/eslint-scope': 3.7.7 '@types/eslint-scope': 3.7.7