feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled

Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.

Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.

Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-06 13:56:01 +01:00
parent 99d9d19079
commit 1aaa483d60
16 changed files with 4187 additions and 21 deletions

View File

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

View File

@@ -161,9 +161,24 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| remember(content, tags?) | Store persistent knowledge with optional tags. |
| recall(query) | Full-text search over mesh memory. |
| forget(id) | Soft-delete a memory entry. |
| share_file(path, name?, tags?) | Share a persistent file with the mesh. |
| get_file(id, save_to) | Download a shared file to a local path. |
| list_files(query?, from?) | Find files shared in the mesh. |
| file_status(id) | Check who has accessed a file. |
| delete_file(id) | Remove a shared file from the mesh. |
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
Multi-target: send_message accepts an array of targets for the 'to' field.
send_message(to: ["Alice", "@backend"], message: "sprint starts")
Targets are deduplicated — each peer receives the message once.
Targeted views: when different audiences need different details about the same event,
send tailored messages instead of one generic broadcast:
send_message(to: "@frontend", message: "Auth v2: useAuth hook changed, see src/auth/")
send_message(to: "@backend", message: "Auth v2: new /api/auth/v2 endpoints, v1 deprecated")
send_message(to: "@pm", message: "Auth v2 done. 3 points, no blockers.")
## Groups
Groups are routing labels. Send to @groupname to multicast to all members. Roles are metadata that peers interpret: a "lead" gathers input before synthesizing a response, a "member" contributes when asked, an "observer" watches silently. Join and leave groups dynamically with join_group/leave_group. Check list_peers to see who belongs to which groups and their roles.
@@ -173,6 +188,10 @@ Shared key-value store scoped to the mesh. Use get_state/set_state for live coor
## Memory
Persistent knowledge that survives across sessions. Use remember(content, tags?) to store lessons, decisions, and incidents. Use recall(query) to search before asking peers. New peers should recall at session start to load institutional knowledge.
## Files
share_file for persistent references, send_message(file:) for ephemeral attachments.
Tags on shared files make them searchable. Use list_files to find what peers shared.
## Priority
- "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue)
- "next" (default): deliver when recipient goes idle (normal coordination)
@@ -201,22 +220,32 @@ Call list_peers at session start to understand who is online, their roles, and w
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
const { client, targetSpec, error } = await resolveClient(to);
if (!client)
return text(`send_message: ${error ?? "no client resolved"}`, true);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
);
if (!result.ok)
return text(
`send_message failed (${client.meshSlug}): ${result.error}`,
true,
// Handle multi-target: to can be string or string[]
const targets = Array.isArray(to) ? to : [to];
const results: string[] = [];
const seen = new Set<string>(); // dedup by resolved pubkey
for (const target of targets) {
const { client, targetSpec, error } = await resolveClient(target);
if (!client) {
results.push(`${target}: ${error ?? "no client resolved"}`);
continue;
}
if (seen.has(targetSpec)) continue; // dedup
seen.add(targetSpec);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
);
return text(
`Sent to ${targetSpec} via ${client.meshSlug} [${priority ?? "next"}] → ${result.messageId}`,
);
if (!result.ok) {
results.push(`${target}: ${result.error}`);
} else {
results.push(`${target}${result.messageId}`);
}
}
return text(results.join("\n"));
}
case "list_peers": {
@@ -363,6 +392,69 @@ Call list_peers at session start to understand who is online, their roles, and w
return text(`Forgotten: ${id}`);
}
// --- Files ---
case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] };
if (!filePath) return text("share_file: `path` required", true);
const { existsSync } = await import("node:fs");
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0];
if (!client) return text("share_file: not connected", 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})`);
}
case "get_file": {
const { id, save_to } = (args ?? {}) as { id?: string; save_to?: string };
if (!id || !save_to) return text("get_file: `id` and `save_to` required", true);
const client = allClients()[0];
if (!client) return text("get_file: not connected", true);
const result = await client.getFile(id);
if (!result) return text(`get_file: file ${id} not found`, true);
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, Buffer.from(await res.arrayBuffer()));
return text(`Downloaded: ${result.name}${save_to}`);
}
case "list_files": {
const { query, from } = (args ?? {}) as { query?: string; from?: string };
const client = allClients()[0];
if (!client) return text("list_files: not connected", true);
const files = await client.listFiles(query, from);
if (files.length === 0) return text("No files found.");
const lines = files.map(f =>
`- **${f.name}** (${f.id.slice(0, 8)}…, ${f.size} bytes) by ${f.uploadedBy}${f.tags.length ? ` [${f.tags.join(", ")}]` : ""}`
);
return text(lines.join("\n"));
}
case "file_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("file_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("file_status: not connected", true);
const accesses = await client.fileStatus(id);
if (accesses.length === 0) return text("No one has accessed this file yet.");
const lines = accesses.map(a => `- ${a.peerName} at ${a.accessedAt}`);
return text(`Accessed by:\n${lines.join("\n")}`);
}
case "delete_file": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("delete_file: `id` required", true);
const client = allClients()[0];
if (!client) return text("delete_file: not connected", true);
await client.deleteFile(id);
return text(`Deleted: ${id}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}

View File

@@ -17,8 +17,11 @@ export const TOOLS: Tool[] = [
type: "object",
properties: {
to: {
type: "string",
description: "Peer name, pubkey, @group, or #channel",
oneOf: [
{ type: "string", description: "Peer name, pubkey, @group" },
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
],
description: "Single target or array of targets",
},
message: { type: "string", description: "Message text" },
priority: {
@@ -195,4 +198,75 @@ export const TOOLS: Tool[] = [
required: ["id"],
},
},
// --- File tools ---
{
name: "share_file",
description:
"Share a persistent file with the mesh. All current and future peers can access it.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Local file path to share" },
name: {
type: "string",
description: "Display name (defaults to filename)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["path"],
},
},
{
name: "get_file",
description: "Download a shared file to a local path.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
save_to: {
type: "string",
description: "Local path to save the file",
},
},
required: ["id", "save_to"],
},
},
{
name: "list_files",
description: "List files shared in the mesh.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search by name or tags" },
from: { type: "string", description: "Filter by uploader name" },
},
},
},
{
name: "file_status",
description: "Check who has accessed a shared file.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
{
name: "delete_file",
description: "Remove a shared file from the mesh.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
];

View File

@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string; // peer name, pubkey, or #channel
to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string;
priority?: Priority;
}

View File

@@ -412,6 +412,9 @@ export class BrokerClient {
/** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = [];
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
@@ -425,6 +428,95 @@ export class BrokerClient {
});
}
// --- Files ---
/** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.fileUrlResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_file", fileId }));
setTimeout(() => {
const idx = this.fileUrlResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileUrlResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** List files shared in the mesh. */
async listFiles(query?: string, from?: string): Promise<Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_files", query, from }));
setTimeout(() => {
const idx = this.fileListResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Check who has accessed a shared file. */
async fileStatus(fileId: string): Promise<Array<{ peerName: string; accessedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "file_status", fileId }));
setTimeout(() => {
const idx = this.fileStatusResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileStatusResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Delete a shared file from the mesh. */
async deleteFile(fileId: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
}
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> {
const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path");
const data = readFileSync(filePath);
const fileName = opts.name ?? basename(filePath);
// Convert WS broker URL to HTTP
const brokerHttp = this.mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
const res = await fetch(`${brokerHttp}/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Mesh-Id": meshId,
"X-Member-Id": memberId,
"X-File-Name": fileName,
"X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "",
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string };
return body.fileId ?? null;
}
/** Subscribe to state change notifications. Returns an unsubscribe function. */
onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void {
this.stateChangeHandlers.add(handler);
@@ -583,6 +675,29 @@ export class BrokerClient {
if (resolver) resolver(msg as any);
return;
}
if (msg.type === "file_url") {
const resolver = this.fileUrlResolvers.shift();
if (resolver) {
if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") });
} else {
resolver(null);
}
}
return;
}
if (msg.type === "file_list") {
const files = (msg.files as Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) ?? [];
const resolver = this.fileListResolvers.shift();
if (resolver) resolver(files);
return;
}
if (msg.type === "file_status_result") {
const accesses = (msg.accesses as Array<{ peerName: string; accessedAt: string }>) ?? [];
const resolver = this.fileStatusResolvers.shift();
if (resolver) resolver(accesses);
return;
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;