Compare commits
22 Commits
v0.5.0
...
579d0c3d3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c | ||
|
|
820ec085b2 | ||
|
|
9e6f6d7bc9 | ||
|
|
7194e7d28e |
214
SPEC.md
214
SPEC.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -1302,11 +1302,28 @@ export async function drainForMember(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||||
// for each group the peer belongs to.
|
// for each group the peer belongs to, expanded to all ancestor paths.
|
||||||
|
//
|
||||||
|
// Hierarchical routing (downward propagation):
|
||||||
|
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
|
||||||
|
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
|
||||||
|
// This lets leads send to a parent group and reach all sub-teams.
|
||||||
|
//
|
||||||
|
// Resolution happens at drain time (pull model) — no duplicates stored,
|
||||||
|
// no schema changes, fully backward-compatible.
|
||||||
const groupTargets = ["@all"];
|
const groupTargets = ["@all"];
|
||||||
if (memberGroups) {
|
if (memberGroups) {
|
||||||
|
const seen = new Set<string>();
|
||||||
for (const g of memberGroups) {
|
for (const g of memberGroups) {
|
||||||
groupTargets.push(`@${g}`);
|
const parts = g.split("/");
|
||||||
|
// Add the group itself + every ancestor prefix.
|
||||||
|
for (let depth = parts.length; depth > 0; depth--) {
|
||||||
|
const ancestor = parts.slice(0, depth).join("/");
|
||||||
|
if (!seen.has(ancestor)) {
|
||||||
|
seen.add(ancestor);
|
||||||
|
groupTargets.push(`@${ancestor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const groupTargetList = sql.raw(
|
const groupTargetList = sql.raw(
|
||||||
@@ -1337,7 +1354,7 @@ export async function drainForMember(
|
|||||||
AND delivered_at IS NULL
|
AND delivered_at IS NULL
|
||||||
AND priority::text IN (${priorityList})
|
AND priority::text IN (${priorityList})
|
||||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const envSchema = z.object({
|
|||||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||||
MINIO_USE_SSL: z.coerce.boolean().default(false),
|
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||||
NEO4J_USER: z.string().default("neo4j"),
|
NEO4J_USER: z.string().default("neo4j"),
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ async function maybePushQueuedMessages(
|
|||||||
excludeSenderSessionPubkey?: string,
|
excludeSenderSessionPubkey?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const conn = connections.get(presenceId);
|
const conn = connections.get(presenceId);
|
||||||
if (!conn) return;
|
if (!conn) {
|
||||||
|
log.debug("maybePush: no connection for presence", { presence_id: presenceId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const status = await refreshStatusFromJsonl(
|
const status = await refreshStatusFromJsonl(
|
||||||
presenceId,
|
presenceId,
|
||||||
conn.cwd,
|
conn.cwd,
|
||||||
@@ -138,6 +141,13 @@ async function maybePushQueuedMessages(
|
|||||||
excludeSenderSessionPubkey,
|
excludeSenderSessionPubkey,
|
||||||
conn.groups.map((g) => g.name),
|
conn.groups.map((g) => g.name),
|
||||||
);
|
);
|
||||||
|
log.info("maybePush", {
|
||||||
|
presence_id: presenceId,
|
||||||
|
status,
|
||||||
|
session_pubkey: conn.sessionPubkey?.slice(0, 12),
|
||||||
|
exclude: excludeSenderSessionPubkey?.slice(0, 12),
|
||||||
|
drained: messages.length,
|
||||||
|
});
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const push: WSPushMessage = {
|
const push: WSPushMessage = {
|
||||||
type: "push",
|
type: "push",
|
||||||
@@ -435,16 +445,20 @@ function handleUploadPost(
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Insert DB row
|
// Insert DB row — normalise tags to a real JS Array (Drizzle PgArray
|
||||||
|
// mapper calls .map() on the value; non-Array iterables break it).
|
||||||
|
// Skip uploadedByMember FK — memberId from the client header is the
|
||||||
|
// mesh slug, not a mesh.member primary key.
|
||||||
const dbFileId = await uploadFile({
|
const dbFileId = await uploadFile({
|
||||||
meshId,
|
meshId,
|
||||||
name: fileName,
|
name: fileName,
|
||||||
sizeBytes: body.length,
|
sizeBytes: body.length,
|
||||||
mimeType: (req.headers["content-type"] as string) || undefined,
|
mimeType: (req.headers["content-type"] as string) || undefined,
|
||||||
minioKey,
|
minioKey,
|
||||||
tags,
|
tags: Array.isArray(tags) ? tags : [],
|
||||||
persistent,
|
persistent,
|
||||||
uploadedByMember: memberId,
|
uploadedByName: memberId || undefined,
|
||||||
|
uploadedByMember: undefined,
|
||||||
targetSpec: targetSpec || undefined,
|
targetSpec: targetSpec || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1658,6 +1672,9 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
|
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
|
||||||
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
|
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
|
||||||
const peerConn = connections.get(presenceId);
|
const peerConn = connections.get(presenceId);
|
||||||
|
// Find own display name: match sessionPubkey from the peer list
|
||||||
|
const selfPubkey = peerConn?.sessionPubkey ?? peerConn?.memberPubkey;
|
||||||
|
const selfPeer = peers.find(p => p.pubkey === selfPubkey);
|
||||||
sendToPeer(presenceId, {
|
sendToPeer(presenceId, {
|
||||||
type: "mesh_info_result",
|
type: "mesh_info_result",
|
||||||
mesh: conn.meshId,
|
mesh: conn.meshId,
|
||||||
@@ -1670,7 +1687,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
streams: streams.map(s => s.name),
|
streams: streams.map(s => s.name),
|
||||||
tables: tables.map((t: any) => t.name),
|
tables: tables.map((t: any) => t.name),
|
||||||
collections: [],
|
collections: [],
|
||||||
yourName: peerConn?.groups?.[0]?.name ?? "unknown",
|
yourName: selfPeer?.displayName ?? "unknown",
|
||||||
yourGroups: peerConn?.groups ?? [],
|
yourGroups: peerConn?.groups ?? [],
|
||||||
});
|
});
|
||||||
log.info("ws mesh_info", { presence_id: presenceId });
|
log.info("ws mesh_info", { presence_id: presenceId });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"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",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
"citty": "0.2.2",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "4.1.13"
|
"zod": "4.1.13"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||||
*
|
*
|
||||||
|
* Flags are defined in index.ts (citty command) — that is the source of
|
||||||
|
* truth. This file receives already-parsed flags and rawArgs.
|
||||||
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||||
* 2. If --join: run join flow first (accepts token or URL)
|
* 2. If --join: run join flow first
|
||||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||||
@@ -18,67 +21,17 @@ import { createInterface } from "node:readline";
|
|||||||
import { loadConfig, getConfigPath } from "../state/config";
|
import { loadConfig, getConfigPath } from "../state/config";
|
||||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||||
|
|
||||||
// --- Arg parsing ---
|
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||||
|
export interface LaunchFlags {
|
||||||
interface LaunchArgs {
|
name?: string;
|
||||||
name: string | null;
|
role?: string;
|
||||||
role: string | null;
|
groups?: string;
|
||||||
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
|
join?: string;
|
||||||
joinLink: string | null;
|
mesh?: string;
|
||||||
meshSlug: string | null;
|
"message-mode"?: string;
|
||||||
quiet: boolean;
|
"system-prompt"?: string;
|
||||||
skipPermConfirm: boolean;
|
yes?: boolean;
|
||||||
claudeArgs: string[];
|
quiet?: boolean;
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(argv: string[]): LaunchArgs {
|
|
||||||
const result: LaunchArgs = {
|
|
||||||
name: null,
|
|
||||||
role: null,
|
|
||||||
groups: null,
|
|
||||||
joinLink: null,
|
|
||||||
meshSlug: null,
|
|
||||||
quiet: false,
|
|
||||||
skipPermConfirm: false,
|
|
||||||
claudeArgs: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
while (i < argv.length) {
|
|
||||||
const arg = argv[i]!;
|
|
||||||
if (arg === "--name" && i + 1 < argv.length) {
|
|
||||||
result.name = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--name=")) {
|
|
||||||
result.name = arg.slice("--name=".length);
|
|
||||||
} else if (arg === "--role" && i + 1 < argv.length) {
|
|
||||||
result.role = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--role=")) {
|
|
||||||
result.role = arg.slice("--role=".length);
|
|
||||||
} else if (arg === "--groups" && i + 1 < argv.length) {
|
|
||||||
result.groups = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--groups=")) {
|
|
||||||
result.groups = arg.slice("--groups=".length);
|
|
||||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
|
||||||
result.joinLink = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--join=")) {
|
|
||||||
result.joinLink = arg.slice("--join=".length);
|
|
||||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
|
||||||
result.meshSlug = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--mesh=")) {
|
|
||||||
result.meshSlug = arg.slice("--mesh=".length);
|
|
||||||
} else if (arg === "--quiet") {
|
|
||||||
result.quiet = true;
|
|
||||||
} else if (arg === "-y" || arg === "--yes") {
|
|
||||||
result.skipPermConfirm = true;
|
|
||||||
} else if (arg === "--") {
|
|
||||||
result.claudeArgs.push(...argv.slice(i + 1));
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
result.claudeArgs.push(arg);
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Interactive mesh picker ---
|
// --- Interactive mesh picker ---
|
||||||
@@ -171,7 +124,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 +136,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);
|
||||||
@@ -194,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
|
|||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||||
const args = parseArgs(extraArgs);
|
// Extract args that follow "--" — passed straight through to claude.
|
||||||
|
const dashIdx = rawArgs.indexOf("--");
|
||||||
|
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||||
|
|
||||||
|
// Normalise flags into the internal shape used below.
|
||||||
|
const args = {
|
||||||
|
name: flags.name ?? null,
|
||||||
|
role: flags.role ?? null,
|
||||||
|
groups: flags.groups ?? null,
|
||||||
|
joinLink: flags.join ?? null,
|
||||||
|
meshSlug: flags.mesh ?? null,
|
||||||
|
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||||
|
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||||
|
: null),
|
||||||
|
systemPrompt: flags["system-prompt"] ?? null,
|
||||||
|
quiet: flags.quiet ?? false,
|
||||||
|
skipPermConfirm: flags.yes ?? false,
|
||||||
|
claudeArgs: claudePassthrough,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. If --join, run join flow first.
|
// 1. If --join, run join flow first.
|
||||||
if (args.joinLink) {
|
if (args.joinLink) {
|
||||||
@@ -263,6 +240,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 +251,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("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +283,9 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
version: 1,
|
version: 1,
|
||||||
meshes: [mesh],
|
meshes: [mesh],
|
||||||
displayName,
|
displayName,
|
||||||
|
...(role ? { role } : {}),
|
||||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
|
messageMode,
|
||||||
};
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmpDir, "config.json"),
|
join(tmpDir, "config.json"),
|
||||||
@@ -302,7 +295,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();
|
||||||
@@ -324,6 +317,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
|
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||||
...filtered,
|
...filtered,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -335,6 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
...process.env,
|
...process.env,
|
||||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||||
|
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* claudemesh-cli entry point.
|
* claudemesh-cli entry point.
|
||||||
*
|
*
|
||||||
|
* Uses citty to define commands and flags. --help is generated from
|
||||||
|
* the command definitions — the flag list here IS the documentation.
|
||||||
|
*
|
||||||
* Dispatches between two modes:
|
* Dispatches between two modes:
|
||||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||||
* - `claudemesh <subcommand>` → CLI subcommand
|
* - `claudemesh <subcommand>` → CLI subcommand
|
||||||
*
|
|
||||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { defineCommand, runMain } from "citty";
|
||||||
import { startMcpServer } from "./mcp/server";
|
import { startMcpServer } from "./mcp/server";
|
||||||
import { runInstall, runUninstall } from "./commands/install";
|
import { runInstall, runUninstall } from "./commands/install";
|
||||||
import { runJoin } from "./commands/join";
|
import { runJoin } from "./commands/join";
|
||||||
@@ -21,96 +23,152 @@ import { runDoctor } from "./commands/doctor";
|
|||||||
import { runWelcome } from "./commands/welcome";
|
import { runWelcome } from "./commands/welcome";
|
||||||
import { VERSION } from "./version";
|
import { VERSION } from "./version";
|
||||||
|
|
||||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
const launch = defineCommand({
|
||||||
|
meta: {
|
||||||
Usage:
|
name: "launch",
|
||||||
claudemesh <command> [args]
|
description: "Launch Claude Code connected to a mesh with real-time peer messaging",
|
||||||
|
},
|
||||||
Commands:
|
args: {
|
||||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
name: {
|
||||||
(add --no-hooks for bare MCP registration)
|
type: "string",
|
||||||
uninstall Remove MCP server + hooks
|
description: "Display name for this session",
|
||||||
launch [opts] Launch Claude Code with real-time push messages
|
},
|
||||||
--name <name> Display name for this session
|
role: {
|
||||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
type: "string",
|
||||||
--join <url> Join a mesh before launching
|
description: "Role tag (dev, lead, analyst — free-form)",
|
||||||
--quiet Skip the info banner
|
},
|
||||||
-- <args> Pass remaining args to claude
|
groups: {
|
||||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
type: "string",
|
||||||
list Show all joined meshes
|
description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
|
||||||
leave <slug> Leave a joined mesh
|
},
|
||||||
status Health report: broker reachability per joined mesh
|
mesh: {
|
||||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
type: "string",
|
||||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
description: "Select mesh by slug (interactive picker if omitted and >1 joined)",
|
||||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
},
|
||||||
--help, -h Show this help
|
join: {
|
||||||
--version, -v Show the CLI version
|
type: "string",
|
||||||
|
description: "Join a mesh via invite URL before launching",
|
||||||
Environment:
|
},
|
||||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
"message-mode": {
|
||||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
type: "string",
|
||||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
description: "push (default) | inbox | off — controls how peer messages are delivered",
|
||||||
`;
|
},
|
||||||
|
"system-prompt": {
|
||||||
const cmd = process.argv[2];
|
type: "string",
|
||||||
const args = process.argv.slice(3);
|
description: "Set Claude's system prompt for this session",
|
||||||
|
},
|
||||||
async function main(): Promise<void> {
|
yes: {
|
||||||
switch (cmd) {
|
type: "boolean",
|
||||||
case "mcp":
|
alias: "y",
|
||||||
await startMcpServer();
|
description: "Skip permission confirmation",
|
||||||
return;
|
default: false,
|
||||||
case "install":
|
},
|
||||||
runInstall(args);
|
quiet: {
|
||||||
return;
|
type: "boolean",
|
||||||
case "uninstall":
|
description: "Skip banner and all interactive prompts",
|
||||||
runUninstall();
|
default: false,
|
||||||
return;
|
},
|
||||||
case "hook":
|
},
|
||||||
await runHook(args);
|
run({ args, rawArgs }) {
|
||||||
return;
|
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||||
case "launch":
|
return runLaunch(args, rawArgs);
|
||||||
await runLaunch(args);
|
},
|
||||||
return;
|
|
||||||
case "join":
|
|
||||||
await runJoin(args);
|
|
||||||
return;
|
|
||||||
case "list":
|
|
||||||
runList();
|
|
||||||
return;
|
|
||||||
case "leave":
|
|
||||||
runLeave(args);
|
|
||||||
return;
|
|
||||||
case "status":
|
|
||||||
await runStatus();
|
|
||||||
return;
|
|
||||||
case "doctor":
|
|
||||||
await runDoctor();
|
|
||||||
return;
|
|
||||||
case "seed-test-mesh":
|
|
||||||
runSeedTestMesh(args);
|
|
||||||
return;
|
|
||||||
case "--version":
|
|
||||||
case "-v":
|
|
||||||
case "version":
|
|
||||||
console.log(VERSION);
|
|
||||||
return;
|
|
||||||
case "--help":
|
|
||||||
case "-h":
|
|
||||||
case "help":
|
|
||||||
console.log(HELP);
|
|
||||||
return;
|
|
||||||
case undefined:
|
|
||||||
runWelcome();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.error(`Unknown command: ${cmd}`);
|
|
||||||
console.error("Run `claudemesh --help` for usage.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const install = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "install",
|
||||||
|
description: "Register MCP server + status hooks with Claude Code",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
"no-hooks": {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Register MCP server only, skip hooks",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ rawArgs }) {
|
||||||
|
runInstall(rawArgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const join = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "join",
|
||||||
|
description: "Join a mesh via invite URL",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
url: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Invite URL (https://claudemesh.com/join/...)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args }) {
|
||||||
|
return runJoin([args.url]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leave = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "leave",
|
||||||
|
description: "Leave a joined mesh",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
slug: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Mesh slug to leave",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args }) {
|
||||||
|
runLeave([args.slug]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "claudemesh",
|
||||||
|
version: VERSION,
|
||||||
|
description: "Peer mesh for Claude Code sessions",
|
||||||
|
},
|
||||||
|
subCommands: {
|
||||||
|
launch,
|
||||||
|
install,
|
||||||
|
uninstall: defineCommand({
|
||||||
|
meta: { name: "uninstall", description: "Remove MCP server and hooks" },
|
||||||
|
run() { runUninstall(); },
|
||||||
|
}),
|
||||||
|
join,
|
||||||
|
list: defineCommand({
|
||||||
|
meta: { name: "list", description: "Show joined meshes and identities" },
|
||||||
|
run() { runList(); },
|
||||||
|
}),
|
||||||
|
leave,
|
||||||
|
status: defineCommand({
|
||||||
|
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
|
||||||
|
async run() { await runStatus(); },
|
||||||
|
}),
|
||||||
|
doctor: defineCommand({
|
||||||
|
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH" },
|
||||||
|
async run() { await runDoctor(); },
|
||||||
|
}),
|
||||||
|
mcp: defineCommand({
|
||||||
|
meta: { name: "mcp", description: "Start MCP server (stdio — invoked by Claude Code, not users)" },
|
||||||
|
async run() { await startMcpServer(); },
|
||||||
|
}),
|
||||||
|
"seed-test-mesh": defineCommand({
|
||||||
|
meta: { name: "seed-test-mesh", description: "Dev only: inject a mesh into config (skips invite flow)" },
|
||||||
|
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||||
|
}),
|
||||||
|
hook: defineCommand({
|
||||||
|
meta: { name: "hook", description: "Internal hook handler (invoked by Claude Code hooks)" },
|
||||||
|
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
runWelcome();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runMain(main);
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ export async function startMcpServer(): Promise<void> {
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
const myName = config.displayName ?? "unnamed";
|
const myName = config.displayName ?? "unnamed";
|
||||||
|
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
|
||||||
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" },
|
||||||
@@ -140,7 +142,7 @@ export async function startMcpServer(): Promise<void> {
|
|||||||
tools: {},
|
tools: {},
|
||||||
},
|
},
|
||||||
instructions: `## Identity
|
instructions: `## Identity
|
||||||
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||||
|
|
||||||
## Responding to messages
|
## Responding to messages
|
||||||
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
|
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
|
||||||
@@ -236,7 +238,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 +446,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 +711,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 +776,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 +821,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 +860,42 @@ Call list_peers at session start to understand who is online, their roles, and w
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcome notification: give Claude immediate context on connect.
|
||||||
|
// Triggers Claude to call mesh_info/list_peers without user input.
|
||||||
|
setTimeout(async () => {
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client || client.status !== "open") return;
|
||||||
|
try {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const peerNames = peers
|
||||||
|
.filter(p => p.displayName !== myName)
|
||||||
|
.map(p => p.displayName)
|
||||||
|
.join(", ") || "none";
|
||||||
|
await server.notification({
|
||||||
|
method: "notifications/claude/channel",
|
||||||
|
params: {
|
||||||
|
content: `[system] Connected as ${myName} to mesh ${client.meshSlug}. ${peers.length} peer(s) online: ${peerNames}. Call mesh_info for full details or set_summary to announce yourself.`,
|
||||||
|
meta: { kind: "welcome", mesh_slug: client.meshSlug },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}, 3_000); // 3s delay: let WS connect + hello_ack complete first
|
||||||
|
|
||||||
|
// 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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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\"])",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export interface Config {
|
|||||||
version: 1;
|
version: 1;
|
||||||
meshes: JoinedMesh[];
|
meshes: JoinedMesh[];
|
||||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||||
|
role?: string; // per-session role tag (display + hello)
|
||||||
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 +55,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, role: parsed.role, 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)}`,
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { env } from "../env";
|
|||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
let configDisplayName: string | undefined;
|
let configDisplayName: string | undefined;
|
||||||
|
let configGroups: Config["groups"] = [];
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
clients.set(mesh.meshId, client);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
// Auto-join groups declared at launch time (--groups flag or config).
|
||||||
|
for (const g of configGroups ?? []) {
|
||||||
|
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Connect failed → client is in "reconnecting" state, leave it
|
// Connect failed → client is in "reconnecting" state, leave it
|
||||||
// wired so tool calls can surface the status.
|
// wired so tool calls can surface the status.
|
||||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
configDisplayName = config.displayName;
|
configDisplayName = config.displayName;
|
||||||
|
configGroups = config.groups ?? [];
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
60
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user