EXTENSION seed
channels
Direct named signal paths between two specific nodes. Bypass the propagation tree walk entirely. When /Health/Fitness and /Health/Food need to exchange signals constantly, propagation walks the tree every time: up to parent, down to sibling. Channels create a direct wire. Signal goes from Fitness to Food in one hop. No tree walk. No intermediate nodes. Channels are named. Multiple channels can exist between the same pair of nodes. A 'nutrition-fitness' channel carries dietary data. A 'recovery' channel carries rest and injury data. Different channels, different filters, same two endpoints. Both propagation and channels use the same underlying deliverCascade kernel function. Both write results to .flow. Both respect cascadeMaxDepth. The difference is routing: propagation routes by tree structure, channels route by explicit subscription. Channels registers its onCascade handler after propagation so nearby nodes receive signals through the tree walk before distant partners receive them through the shortcut. Loop prevention: channel deliveries are tagged with _channel in the payload. The onCascade handler skips any signal already carrying a _channel tag. One hop only. Channel signals never re-enter the channel system. Same pattern as mycelium's _myceliumRouted array preventing triangle loops. Channel creation requires consent from both endpoints. Same-owner nodes on the same land auto-accept. Different owners or cross-land channels require an invitation signal that the receiving side accepts explicitly. Sovereignty preserved.
v1.0.1 by TreeOS Site 0 downloads 5 files 704 lines 24.8 KB published 38d ago
treeos ext install channels
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks
  • models: Node
  • extensions: propagation

Optional

  • services: energy
  • extensions: perspective-filter
SHA256: f80d35b5edc00b391e3543fe49fd1f2a65f4db16a0b80a29b61b172d39715828

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
channelsGETDirect signal paths. Actions: create, remove, status.
channels createPOSTCreate a named channel to another node
channels removeDELETERemove a channel
channels statusGETSignal stats on a channel

Hooks

Listens To

  • onCascade
  • enrichContext

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star channels

Comments

Loading comments...

Post comments from the CLI: treeos ext comment channels "your comment"
Max 3 comments per extension. One star and one flag per user.