1// Channels Core
2//
3// Direct named signal paths between nodes. One-hop delivery via deliverCascade.
4// No tree walk. Subscriptions stored in metadata.channels on each endpoint.
5
6import log from "../../seed/log.js";
7import { deliverCascade } from "../../seed/tree/cascade.js";
8import { v4 as uuidv4 } from "uuid";
9
10let Node = null;
11let _metadata = null;
12export function setServices({ models, metadata }) {
13 Node = models.Node;
14 if (metadata) _metadata = metadata;
15}
16
17const CHANNEL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,49}$/;
18const MAX_CHANNELS_PER_NODE = 50;
19
20// ─────────────────────────────────────────────────────────────────────────
21// READ
22// ─────────────────────────────────────────────────────────────────────────
23
24export async function getChannels(nodeId) {
25 const node = await Node.findById(nodeId).select("metadata").lean();
26 if (!node) throw new Error("Node not found");
27 const meta = _metadata.getExtMeta(node, "channels");
28 return {
29 subscriptions: meta.subscriptions || [],
30 pending: meta.pending || [],
31 };
32}
33
34// ─────────────────────────────────────────────────────────────────────────
35// CREATE
36// ─────────────────────────────────────────────────────────────────────────
37
38export async function createChannel({
39 sourceNodeId,
40 targetNodeId,
41 channelName,
42 direction = "bidirectional",
43 filter = null,
44 userId,
45}) {
46 if (!CHANNEL_NAME_RE.test(channelName)) {
47 throw new Error("Channel name must be 1-50 alphanumeric characters, hyphens, or underscores");
48 }
49 if (!["inbound", "outbound", "bidirectional"].includes(direction)) {
50 throw new Error("Direction must be inbound, outbound, or bidirectional");
51 }
52 if (sourceNodeId === targetNodeId) {
53 throw new Error("Cannot create a channel from a node to itself");
54 }
55
56 const sourceNode = await Node.findById(sourceNodeId).select("_id name rootOwner metadata").lean();
57 if (!sourceNode) throw new Error("Source node not found");
58
59 const targetNode = await Node.findById(targetNodeId).select("_id name rootOwner metadata").lean();
60 if (!targetNode) throw new Error("Target node not found");
61
62 // Check for duplicate channel name between this pair
63 const sourceMeta = _metadata.getExtMeta(sourceNode, "channels");
64 const subs = sourceMeta.subscriptions || [];
65 if (subs.length >= MAX_CHANNELS_PER_NODE) {
66 throw new Error(`Maximum ${MAX_CHANNELS_PER_NODE} channels per node`);
67 }
68 const duplicate = subs.find(
69 s => s.channelName === channelName && s.partnerId === targetNodeId,
70 );
71 if (duplicate) {
72 throw new Error(`Channel "${channelName}" already exists between these nodes`);
73 }
74
75 // Determine if auto-accept applies (same owner)
76 const sameOwner = sourceNode.rootOwner && targetNode.rootOwner &&
77 sourceNode.rootOwner.toString() === targetNode.rootOwner.toString();
78
79 const now = new Date().toISOString();
80
81 if (sameOwner) {
82 // Auto-accept: write subscription to both sides
83 await writeSubscription(sourceNodeId, {
84 channelName,
85 partnerId: targetNodeId,
86 partnerName: targetNode.name,
87 direction,
88 filter,
89 createdAt: now,
90 createdBy: userId,
91 active: true,
92 });
93
94 const reverseDirection = direction === "outbound" ? "inbound"
95 : direction === "inbound" ? "outbound"
96 : "bidirectional";
97
98 await writeSubscription(targetNodeId, {
99 channelName,
100 partnerId: sourceNodeId,
101 partnerName: sourceNode.name,
102 direction: reverseDirection,
103 filter,
104 createdAt: now,
105 createdBy: userId,
106 active: true,
107 });
108
109 log.verbose("Channels", `Channel "${channelName}" created between ${sourceNode.name} and ${targetNode.name} (auto-accepted)`);
110
111 return { channelName, status: "active", autoAccepted: true };
112 }
113
114 // Different owner: send invitation via cascade
115 await writeInvitation(targetNodeId, {
116 channelName,
117 fromNodeId: sourceNodeId,
118 fromNodeName: sourceNode.name,
119 direction,
120 filter,
121 invitedAt: now,
122 invitedBy: userId,
123 });
124
125 // Also send a cascade signal so the target's operator is notified
126 try {
127 await deliverCascade({
128 nodeId: targetNodeId,
129 signalId: uuidv4(),
130 payload: {
131 _channelInvite: {
132 channelName,
133 sourceNodeId,
134 sourceNodeName: sourceNode.name,
135 direction,
136 filter,
137 },
138 },
139 source: sourceNodeId,
140 depth: 0,
141 });
142 } catch (err) {
143 log.debug("Channels", `Invitation cascade delivery failed: ${err.message}`);
144 }
145
146 log.verbose("Channels", `Channel invitation "${channelName}" sent from ${sourceNode.name} to ${targetNode.name}`);
147
148 return { channelName, status: "pending", autoAccepted: false };
149}
150
151// ─────────────────────────────────────────────────────────────────────────
152// ACCEPT INVITATION
153// ─────────────────────────────────────────────────────────────────────────
154
155export async function acceptInvite(nodeId, channelName, userId) {
156 const node = await Node.findById(nodeId).select("_id name metadata").lean();
157 if (!node) throw new Error("Node not found");
158
159 const meta = _metadata.getExtMeta(node, "channels");
160 const pending = meta.pending || [];
161 const inviteIdx = pending.findIndex(p => p.channelName === channelName);
162 if (inviteIdx === -1) throw new Error(`No pending invitation for channel "${channelName}"`);
163
164 const invite = pending[inviteIdx];
165 const now = new Date().toISOString();
166
167 // Write subscription on this side
168 await writeSubscription(nodeId, {
169 channelName,
170 partnerId: invite.fromNodeId,
171 partnerName: invite.fromNodeName,
172 direction: invite.direction === "outbound" ? "inbound"
173 : invite.direction === "inbound" ? "outbound"
174 : "bidirectional",
175 filter: invite.filter || null,
176 createdAt: now,
177 createdBy: userId,
178 active: true,
179 });
180
181 // Write subscription on the source side
182 await writeSubscription(invite.fromNodeId, {
183 channelName,
184 partnerId: nodeId,
185 partnerName: node.name,
186 direction: invite.direction,
187 filter: invite.filter || null,
188 createdAt: now,
189 createdBy: invite.invitedBy,
190 active: true,
191 });
192
193 // Remove from pending
194 pending.splice(inviteIdx, 1);
195 const nodeDoc = await Node.findById(nodeId);
196 if (nodeDoc) {
197 const updated = _metadata.getExtMeta(nodeDoc, "channels");
198 updated.pending = pending;
199 await _metadata.setExtMeta(nodeDoc, "channels", updated);
200 }
201
202 log.verbose("Channels", `Channel invitation "${channelName}" accepted at ${node.name}`);
203
204 return { channelName, status: "active" };
205}
206
207// ─────────────────────────────────────────────────────────────────────────
208// REMOVE
209// ─────────────────────────────────────────────────────────────────────────
210
211export async function removeChannel(nodeId, channelName, userId) {
212 // Remove from this side
213 const removed = await removeSubscription(nodeId, channelName);
214 if (!removed) throw new Error(`Channel "${channelName}" not found on this node`);
215
216 // Remove from partner side
217 if (removed.partnerId) {
218 try {
219 await removeSubscription(removed.partnerId, channelName);
220 } catch (err) {
221 log.debug("Channels", `Partner-side removal failed for "${channelName}": ${err.message}`);
222 }
223 }
224
225 log.verbose("Channels", `Channel "${channelName}" removed from node ${nodeId}`);
226
227 return { channelName, removed: true };
228}
229
230// ─────────────────────────────────────────────────────────────────────────
231// DELIVER (called from onCascade handler)
232// ─────────────────────────────────────────────────────────────────────────
233
234export async function deliverToChannels(nodeId, signalPayload, signalId, depth) {
235 const node = await Node.findById(nodeId).select("metadata").lean();
236 if (!node) return [];
237
238 const meta = _metadata.getExtMeta(node, "channels");
239 const subs = (meta.subscriptions || []).filter(
240 s => s.active && (s.direction === "outbound" || s.direction === "bidirectional"),
241 );
242
243 if (subs.length === 0) return [];
244
245 const results = [];
246 const payloadTags = signalPayload.tags || [];
247
248 for (const sub of subs) {
249 // Apply tag filter if configured
250 if (sub.filter?.tags && sub.filter.tags.length > 0) {
251 const overlap = sub.filter.tags.some(t => payloadTags.includes(t));
252 if (!overlap) continue;
253 }
254
255 // Deliver with _channel tag to prevent re-entry
256 try {
257 const result = await deliverCascade({
258 nodeId: sub.partnerId,
259 signalId: signalId || uuidv4(),
260 payload: {
261 ...signalPayload,
262 _channel: sub.channelName,
263 _channelSource: nodeId,
264 },
265 source: nodeId,
266 depth: (depth || 0) + 1,
267 });
268
269 results.push({
270 channelName: sub.channelName,
271 partnerId: sub.partnerId,
272 partnerName: sub.partnerName,
273 status: result?.status || "delivered",
274 });
275 } catch (err) {
276 log.debug("Channels", `Channel delivery failed for "${sub.channelName}" to ${sub.partnerName}: ${err.message}`);
277 results.push({
278 channelName: sub.channelName,
279 partnerId: sub.partnerId,
280 partnerName: sub.partnerName,
281 status: "failed",
282 error: err.message,
283 });
284 }
285 }
286
287 return results;
288}
289
290// ─────────────────────────────────────────────────────────────────────────
291// METADATA HELPERS
292// ─────────────────────────────────────────────────────────────────────────
293
294async function writeSubscription(nodeId, subscription) {
295 const nodeDoc = await Node.findById(nodeId);
296 if (!nodeDoc) throw new Error(`Node ${nodeId} not found for subscription write`);
297
298 const meta = _metadata.getExtMeta(nodeDoc, "channels");
299 if (!meta.subscriptions) meta.subscriptions = [];
300 meta.subscriptions.push(subscription);
301 await _metadata.setExtMeta(nodeDoc, "channels", meta);
302}
303
304async function writeInvitation(nodeId, invitation) {
305 const nodeDoc = await Node.findById(nodeId);
306 if (!nodeDoc) throw new Error(`Node ${nodeId} not found for invitation write`);
307
308 const meta = _metadata.getExtMeta(nodeDoc, "channels");
309 if (!meta.pending) meta.pending = [];
310
311 // Replace existing invitation for same channel name from same source
312 const existingIdx = meta.pending.findIndex(
313 p => p.channelName === invitation.channelName && p.fromNodeId === invitation.fromNodeId,
314 );
315 if (existingIdx >= 0) {
316 meta.pending[existingIdx] = invitation;
317 } else {
318 meta.pending.push(invitation);
319 }
320 await _metadata.setExtMeta(nodeDoc, "channels", meta);
321}
322
323async function removeSubscription(nodeId, channelName) {
324 const nodeDoc = await Node.findById(nodeId);
325 if (!nodeDoc) return null;
326
327 const meta = _metadata.getExtMeta(nodeDoc, "channels");
328 if (!meta.subscriptions) return null;
329
330 const idx = meta.subscriptions.findIndex(s => s.channelName === channelName);
331 if (idx === -1) return null;
332
333 const removed = meta.subscriptions.splice(idx, 1)[0];
334 await _metadata.setExtMeta(nodeDoc, "channels", meta);
335 return removed;
336}
337
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setServices, deliverToChannels, getChannels, createChannel, removeChannel, acceptInvite } from "./core.js";
4export async function init(core) {
5 setServices({ models: core.models, metadata: core.metadata });
6
7 // ── onCascade: deliver signals through channel subscriptions ────────
8 //
9 // Runs AFTER propagation (propagation is a required dependency, so its
10 // handler registered first). Nearby nodes get the signal through the
11 // tree walk before distant partners get it through the channel shortcut.
12 //
13 // Two skip conditions prevent loops and misrouting:
14 // 1. _channel tag present: this signal already arrived via a channel.
15 // One hop only. Never re-enter the channel system.
16 // 2. _channelInvite present: this is an invitation signal, not content.
17 // Handled separately below.
18
19 core.hooks.register("onCascade", async (hookData) => {
20 const { nodeId, payload, signalId, depth } = hookData;
21 if (!payload) return;
22
23 // Skip channel-delivered signals (loop prevention)
24 if (payload._channel) return;
25
26 // Skip invitation signals (handled by the invitation listener below)
27 if (payload._channelInvite) return;
28
29 // Deliver to all matching channel subscriptions
30 const results = await deliverToChannels(nodeId, payload, signalId, depth);
31
32 if (results.length > 0) {
33 log.verbose("Channels", `Delivered signal from ${nodeId} through ${results.length} channel(s)`);
34 }
35
36 return { channelDeliveries: results };
37 }, "channels");
38
39 // ── onCascade: handle channel invitations ───────────────────────────
40 //
41 // When a _channelInvite arrives, auto-accept if same owner.
42 // Otherwise the invitation is already in pending[] from createChannel.
43
44 core.hooks.register("onCascade", async (hookData) => {
45 const { nodeId, payload } = hookData;
46 if (!payload?._channelInvite) return;
47
48 const invite = payload._channelInvite;
49 const Node = core.models.Node;
50
51 // Check if same owner for auto-accept
52 const targetNode = await Node.findById(nodeId).select("rootOwner").lean();
53 const sourceNode = await Node.findById(invite.sourceNodeId).select("rootOwner").lean();
54
55 if (targetNode?.rootOwner && sourceNode?.rootOwner &&
56 targetNode.rootOwner.toString() === sourceNode.rootOwner.toString()) {
57 try {
58 await acceptInvite(nodeId, invite.channelName, "system");
59 log.verbose("Channels", `Auto-accepted channel "${invite.channelName}" from ${invite.sourceNodeName}`);
60 } catch (err) {
61 log.debug("Channels", `Auto-accept failed for "${invite.channelName}": ${err.message}`);
62 }
63 }
64 }, "channels");
65
66 // ── enrichContext: surface channel info to the AI ───────────────────
67
68 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
69 const channelMeta = meta.channels;
70 if (!channelMeta?.subscriptions?.length) return;
71
72 const active = channelMeta.subscriptions.filter(s => s.active);
73 if (active.length === 0) return;
74
75 context.channels = active.map(s => ({
76 name: s.channelName,
77 partner: s.partnerName,
78 direction: s.direction,
79 filter: s.filter?.tags || null,
80 }));
81
82 if (channelMeta.pending?.length > 0) {
83 context.pendingChannelInvites = channelMeta.pending.length;
84 }
85 }, "channels");
86
87 const { default: router } = await import("./routes.js");
88
89 log.info("Channels", "Direct signal channels loaded");
90
91 return {
92 router,
93 tools,
94 exports: {
95 getChannels,
96 createChannel,
97 removeChannel,
98 acceptInvite,
99 deliverToChannels,
100 },
101 };
102}
103
1export default {
2 name: "channels",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "Direct named signal paths between two specific nodes. Bypass the propagation " +
7 "tree walk entirely. When /Health/Fitness and /Health/Food need to exchange " +
8 "signals constantly, propagation walks the tree every time: up to parent, down " +
9 "to sibling. Channels create a direct wire. Signal goes from Fitness to Food " +
10 "in one hop. No tree walk. No intermediate nodes." +
11 "\n\n" +
12 "Channels are named. Multiple channels can exist between the same pair of nodes. " +
13 "A 'nutrition-fitness' channel carries dietary data. A 'recovery' channel carries " +
14 "rest and injury data. Different channels, different filters, same two endpoints." +
15 "\n\n" +
16 "Both propagation and channels use the same underlying deliverCascade kernel " +
17 "function. Both write results to .flow. Both respect cascadeMaxDepth. The " +
18 "difference is routing: propagation routes by tree structure, channels route by " +
19 "explicit subscription. Channels registers its onCascade handler after propagation " +
20 "so nearby nodes receive signals through the tree walk before distant partners " +
21 "receive them through the shortcut." +
22 "\n\n" +
23 "Loop prevention: channel deliveries are tagged with _channel in the payload. " +
24 "The onCascade handler skips any signal already carrying a _channel tag. One hop " +
25 "only. Channel signals never re-enter the channel system. Same pattern as " +
26 "mycelium's _myceliumRouted array preventing triangle loops." +
27 "\n\n" +
28 "Channel creation requires consent from both endpoints. Same-owner nodes on the " +
29 "same land auto-accept. Different owners or cross-land channels require an " +
30 "invitation signal that the receiving side accepts explicitly. Sovereignty preserved.",
31
32 needs: {
33 services: ["hooks"],
34 models: ["Node"],
35 extensions: ["propagation"],
36 },
37
38 optional: {
39 services: ["energy"],
40 extensions: ["perspective-filter"],
41 },
42
43 provides: {
44 models: {},
45 routes: "./routes.js",
46 tools: true,
47 jobs: false,
48 orchestrator: false,
49 energyActions: {},
50 sessionTypes: {},
51
52 hooks: {
53 fires: [],
54 listens: ["onCascade", "enrichContext"],
55 },
56
57 cli: [
58 {
59 command: "channels [action]", scope: ["tree"],
60 description: "Direct signal paths. Actions: create, remove, status.",
61 method: "GET",
62 endpoint: "/node/:nodeId/channels",
63 subcommands: {
64 create: {
65 method: "POST",
66 endpoint: "/node/:nodeId/channels",
67 description: "Create a named channel to another node",
68 bodyMap: { target: 0, name: 1, direction: 2 },
69 },
70 remove: {
71 method: "DELETE",
72 endpoint: "/node/:nodeId/channels/:channelName",
73 description: "Remove a channel",
74 },
75 status: {
76 method: "GET",
77 endpoint: "/node/:nodeId/channels/:channelName",
78 description: "Signal stats on a channel",
79 },
80 },
81 },
82 ],
83 },
84};
85
1import express from "express";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { getChannels, createChannel, removeChannel, acceptInvite } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/channels - List all channels
9router.get("/node/:nodeId/channels", authenticate, async (req, res) => {
10 try {
11 const result = await getChannels(req.params.nodeId);
12 sendOk(res, result);
13 } catch (err) {
14 sendError(res, 400, ERR.INVALID_INPUT, err.message);
15 }
16});
17
18// GET /node/:nodeId/channels/:channelName - Channel detail/status
19router.get("/node/:nodeId/channels/:channelName", authenticate, async (req, res) => {
20 try {
21 const { subscriptions, pending } = await getChannels(req.params.nodeId);
22 const sub = subscriptions.find(s => s.channelName === req.params.channelName);
23 const invite = pending.find(p => p.channelName === req.params.channelName);
24 if (!sub && !invite) {
25 return sendError(res, 404, ERR.NODE_NOT_FOUND, `Channel "${req.params.channelName}" not found`);
26 }
27 sendOk(res, { subscription: sub || null, pending: invite || null });
28 } catch (err) {
29 sendError(res, 400, ERR.INVALID_INPUT, err.message);
30 }
31});
32
33// POST /node/:nodeId/channels - Create a channel
34router.post("/node/:nodeId/channels", authenticate, async (req, res) => {
35 try {
36 const { target, name, direction, filter } = req.body;
37 if (!target) return sendError(res, 400, ERR.INVALID_INPUT, "target (node ID) is required");
38 if (!name) return sendError(res, 400, ERR.INVALID_INPUT, "name (channel name) is required");
39
40 const result = await createChannel({
41 sourceNodeId: req.params.nodeId,
42 targetNodeId: target,
43 channelName: name,
44 direction: direction || "bidirectional",
45 filter: filter || null,
46 userId: req.userId,
47 });
48 sendOk(res, result, 201);
49 } catch (err) {
50 sendError(res, 400, ERR.INVALID_INPUT, err.message);
51 }
52});
53
54// DELETE /node/:nodeId/channels/:channelName - Remove a channel
55router.delete("/node/:nodeId/channels/:channelName", authenticate, async (req, res) => {
56 try {
57 const result = await removeChannel(req.params.nodeId, req.params.channelName, req.userId);
58 sendOk(res, result);
59 } catch (err) {
60 sendError(res, 400, ERR.INVALID_INPUT, err.message);
61 }
62});
63
64// POST /node/:nodeId/channels/:channelName/accept - Accept invitation
65router.post("/node/:nodeId/channels/:channelName/accept", authenticate, async (req, res) => {
66 try {
67 const result = await acceptInvite(req.params.nodeId, req.params.channelName, req.userId);
68 sendOk(res, result);
69 } catch (err) {
70 sendError(res, 400, ERR.INVALID_INPUT, err.message);
71 }
72});
73
74export default router;
75
1import { z } from "zod";
2import { getChannels, createChannel, removeChannel } from "./core.js";
3
4export default [
5 {
6 name: "channel-list",
7 description: "Show active channels and pending invitations at this node.",
8 schema: {
9 nodeId: z.string().describe("Node to list channels for."),
10 userId: z.string().describe("Injected by server. Ignore."),
11 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
12 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13 },
14 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
15 handler: async ({ nodeId }) => {
16 try {
17 const result = await getChannels(nodeId);
18 if (result.subscriptions.length === 0 && result.pending.length === 0) {
19 return { content: [{ type: "text", text: "No channels or pending invitations at this node." }] };
20 }
21 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
22 } catch (err) {
23 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
24 }
25 },
26 },
27 {
28 name: "channel-create",
29 description:
30 "Create a named direct signal channel to another node. Signals bypass the " +
31 "propagation tree walk and arrive in one hop.",
32 schema: {
33 nodeId: z.string().describe("Source node (this end of the channel)."),
34 targetNodeId: z.string().describe("Target node (the other end)."),
35 channelName: z.string().describe("Name for the channel (alphanumeric, hyphens, underscores, max 50)."),
36 direction: z.enum(["inbound", "outbound", "bidirectional"]).optional().default("bidirectional")
37 .describe("Signal direction. Bidirectional means both ends send and receive."),
38 userId: z.string().describe("Injected by server. Ignore."),
39 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
40 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
41 },
42 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
43 handler: async ({ nodeId, targetNodeId, channelName, direction, userId }) => {
44 try {
45 const result = await createChannel({
46 sourceNodeId: nodeId,
47 targetNodeId,
48 channelName,
49 direction,
50 userId,
51 });
52 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
53 } catch (err) {
54 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
55 }
56 },
57 },
58 {
59 name: "channel-remove",
60 description: "Remove a named channel from this node. Cleans up both endpoints.",
61 schema: {
62 nodeId: z.string().describe("Node to remove the channel from."),
63 channelName: z.string().describe("Name of the channel to remove."),
64 userId: z.string().describe("Injected by server. Ignore."),
65 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
66 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
67 },
68 annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
69 handler: async ({ nodeId, channelName, userId }) => {
70 try {
71 const result = await removeChannel(nodeId, channelName, userId);
72 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
73 } catch (err) {
74 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
75 }
76 },
77 },
78 {
79 name: "channel-status",
80 description: "Show detail and signal stats for a specific channel.",
81 schema: {
82 nodeId: z.string().describe("Node to check."),
83 channelName: z.string().describe("Channel name to inspect."),
84 userId: z.string().describe("Injected by server. Ignore."),
85 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
86 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
87 },
88 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
89 handler: async ({ nodeId, channelName }) => {
90 try {
91 const { subscriptions, pending } = await getChannels(nodeId);
92 const sub = subscriptions.find(s => s.channelName === channelName);
93 const invite = pending.find(p => p.channelName === channelName);
94 if (!sub && !invite) {
95 return { content: [{ type: "text", text: `Channel "${channelName}" not found at this node.` }] };
96 }
97 return { content: [{ type: "text", text: JSON.stringify({ subscription: sub || null, pending: invite || null }, null, 2) }] };
98 } catch (err) {
99 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
100 }
101 },
102 },
103];
104
Loading comments...