feat(daemon+mcp): daemon required for in-Claude-Code use; thin MCP shim
The architectural convergence v0.9.0 was building toward. CLI keeps working without a daemon (claudemesh send/peer/inbox/...), but the MCP push-pipe — which Claude Code uses for mid-turn channel emits, slash commands, and resources — now requires the daemon. There is no fallback. Daemon (additive): - /v1/skills (list) and /v1/skills/:name (get) IPC endpoints, so the MCP shim can surface mesh skills without holding its own broker WS. - listSkills() / getSkill() on DaemonBrokerClient. - SSE 'message' event now carries plaintext body, sender_member_pubkey, priority, and subtype — full payload the MCP shim needs to render a channel notification. MCP server: 979 → 469 LoC (470 of the remaining 469 is the unrelated mesh-service proxy mode; the push-pipe path is ~200 LoC including boilerplate). - Probes ~/.claudemesh/daemon/daemon.sock at boot. Bails loudly with actionable instructions if missing. - Subscribes to /v1/events SSE and translates each event into a notifications/claude/channel emit. - Fetches mesh skills from the daemon for ListPrompts/GetPrompt and ListResources/ReadResource. ListTools returns []; the CLI is the API. - No broker WS, no decryption, no reconnect logic. Daemon owns all of it. claudemesh install: auto-installs and starts the daemon service for the user's primary mesh (launchd / systemd-user). Pass --no-service to skip. claudemesh launch: probes the daemon socket; if absent, spawns 'claudemesh daemon up --mesh <slug>' detached and waits up to 10s for the socket. Surfaces a clear warning on timeout but doesn't block — Claude Code's MCP shim will print the same error if the daemon really isn't there. Bundle: dist/entrypoints/mcp.js drops from 154KB → 104KB (gzipped 34KB → 19KB). Test: MCP boots cleanly via stdio, declares correct capabilities, talks JSON-RPC; daemon /v1/skills returns the empty list as expected on a mesh with no skills. Released as 1.24.0 on npm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,19 @@ interface PendingPeerList {
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SkillFull extends SkillSummary {
|
||||
instructions: string;
|
||||
manifest?: unknown;
|
||||
}
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const SEND_ACK_TIMEOUT_MS = 15_000;
|
||||
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
@@ -76,6 +89,8 @@ export class DaemonBrokerClient {
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private pendingAcks = new Map<string, PendingAck>();
|
||||
private peerListResolvers = new Map<string, PendingPeerList>();
|
||||
private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>();
|
||||
private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>();
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private opens: Array<() => void> = [];
|
||||
@@ -189,6 +204,28 @@ export class DaemonBrokerClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "skill_list") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.skillListResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.skillListResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(Array.isArray(msg.skills) ? (msg.skills as SkillSummary[]) : []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "skill_data") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.skillDataResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.skillDataResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve((msg.skill as SkillFull) ?? null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push" || msg.type === "inbound") {
|
||||
this.opts.onPush?.(msg);
|
||||
return;
|
||||
@@ -264,6 +301,34 @@ export class DaemonBrokerClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** List mesh-published skills. Empty array on disconnect / timeout. */
|
||||
async listSkills(query?: string, timeoutMs = 5_000): Promise<SkillSummary[]> {
|
||||
if (this._status !== "open" || !this.ws) return [];
|
||||
return new Promise<SkillSummary[]>((resolve) => {
|
||||
const reqId = `sl-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.skillListResolvers.delete(reqId)) resolve([]);
|
||||
}, timeoutMs);
|
||||
this.skillListResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "list_skills", query, _reqId: reqId })); }
|
||||
catch { this.skillListResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch one skill's full body. Null on not-found / disconnect / timeout. */
|
||||
async getSkill(name: string, timeoutMs = 5_000): Promise<SkillFull | null> {
|
||||
if (this._status !== "open" || !this.ws) return null;
|
||||
return new Promise<SkillFull | null>((resolve) => {
|
||||
const reqId = `sg-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.skillDataResolvers.delete(reqId)) resolve(null);
|
||||
}, timeoutMs);
|
||||
this.skillDataResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "get_skill", name, _reqId: reqId })); }
|
||||
catch { this.skillDataResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */
|
||||
setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void {
|
||||
if (this._status !== "open" || !this.ws) return;
|
||||
|
||||
Reference in New Issue
Block a user