wogiflow 2.4.3 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Channel MCP Server
5
+ *
6
+ * Minimal MCP server (JSON-RPC 2.0 over stdio) that receives HTTP webhooks
7
+ * and forwards them as channel notifications to a Claude Code session.
8
+ *
9
+ * Used by workspace workers to receive task dispatches from the manager
10
+ * and questions from peer repos.
11
+ *
12
+ * Environment:
13
+ * WOGI_CHANNEL_PORT — HTTP port to listen on (default: 8801)
14
+ * WOGI_REPO_NAME — Name of this repo in the workspace
15
+ * WOGI_PEERS — Comma-separated peer list: "backend:8802,shared:8803"
16
+ * WOGI_WORKSPACE_ROOT — Path to workspace root (for message bus access)
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const http = require('node:http');
22
+ const readline = require('node:readline');
23
+
24
+ // ============================================================
25
+ // Constants
26
+ // ============================================================
27
+
28
+ const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB max POST body
29
+ const MAX_RESPONSE_BYTES = 64 * 1024; // 64 KB max peer response
30
+ const MIN_PORT = 1024;
31
+ const MAX_PORT = 65535;
32
+ const DEFAULT_PORT = 8801;
33
+ const VALID_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
34
+
35
+ // ============================================================
36
+ // Port Validation
37
+ // ============================================================
38
+
39
+ function validatePort(raw, label) {
40
+ const port = parseInt(raw, 10);
41
+ if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
42
+ process.stderr.write(`[wogi-channel] Invalid ${label}: "${raw}" — must be ${MIN_PORT}-${MAX_PORT}. Defaulting to ${DEFAULT_PORT}\n`);
43
+ return DEFAULT_PORT;
44
+ }
45
+ return port;
46
+ }
47
+
48
+ const PORT = validatePort(process.env.WOGI_CHANNEL_PORT || String(DEFAULT_PORT), 'WOGI_CHANNEL_PORT');
49
+ const RAW_REPO_NAME = process.env.WOGI_REPO_NAME || 'unknown';
50
+ const REPO_NAME = VALID_NAME_PATTERN.test(RAW_REPO_NAME) ? RAW_REPO_NAME : 'unknown';
51
+ const PEERS_RAW = process.env.WOGI_PEERS || '';
52
+ const WORKSPACE_ROOT = process.env.WOGI_WORKSPACE_ROOT || '';
53
+
54
+ // Parse peer list: "backend:8802,shared:8803" → { backend: 8802, shared: 8803 }
55
+ function parsePeers(raw) {
56
+ const peers = {};
57
+ if (!raw) return peers;
58
+ for (const entry of raw.split(',')) {
59
+ const [name, portStr] = entry.trim().split(':');
60
+ if (!name || !portStr) continue;
61
+ const port = parseInt(portStr, 10);
62
+ if (!VALID_NAME_PATTERN.test(name)) {
63
+ process.stderr.write(`[wogi-channel] Ignoring peer with invalid name "${name}"\n`);
64
+ continue;
65
+ }
66
+ if (Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT) {
67
+ peers[name] = port;
68
+ } else {
69
+ process.stderr.write(`[wogi-channel] Ignoring invalid peer "${entry}" — port must be ${MIN_PORT}-${MAX_PORT}\n`);
70
+ }
71
+ }
72
+ return peers;
73
+ }
74
+
75
+ const PEERS = parsePeers(PEERS_RAW);
76
+
77
+ // ============================================================
78
+ // Minimal MCP Protocol (JSON-RPC 2.0 over stdio)
79
+ // ============================================================
80
+
81
+ let initialized = false;
82
+
83
+ /**
84
+ * Send a JSON-RPC message to Claude Code via stdout.
85
+ * MCP uses newline-delimited JSON over stdio.
86
+ */
87
+ function sendMessage(msg) {
88
+ const json = JSON.stringify(msg);
89
+ process.stdout.write(json + '\n');
90
+ }
91
+
92
+ /**
93
+ * Send a JSON-RPC response (reply to a request).
94
+ */
95
+ function sendResponse(id, result) {
96
+ sendMessage({ jsonrpc: '2.0', id, result });
97
+ }
98
+
99
+ /**
100
+ * Send a channel notification to Claude Code.
101
+ * This is what makes the worker "receive" a message from the manager or a peer.
102
+ */
103
+ function sendChannelNotification(content, meta) {
104
+ sendMessage({
105
+ jsonrpc: '2.0',
106
+ method: 'notifications/claude/channel',
107
+ params: { content, meta: meta || {} }
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Build the instructions string for the channel.
113
+ * These tell the Claude Code session how to handle incoming messages.
114
+ */
115
+ function buildInstructions() {
116
+ const peerList = Object.entries(PEERS)
117
+ .map(([name, port]) => ` - ${name}: http://localhost:${port}`)
118
+ .join('\n');
119
+
120
+ const peerSection = peerList
121
+ ? `\n\nYou can communicate directly with peer repos via their channels:\n${peerList}\nTo ask a peer a question: curl -s -X POST http://localhost:{port} -d "your question"\nTo send a task to a peer: curl -s -X POST http://localhost:{port} -d "/wogi-start wf-XXXXXXXX"`
122
+ : '';
123
+
124
+ return `You are "${REPO_NAME}" in a Wogi Workspace. Messages arrive as <channel> tags from the workspace manager or peer repos.
125
+
126
+ When you receive a message:
127
+ 1. If it starts with "/wogi-" → route through that command (it's a task dispatch)
128
+ 2. If it's a question from a peer → read your codebase to answer, then reply via curl to their port
129
+ 3. If it's a status check → respond with your current task status
130
+
131
+ IMPORTANT: Channel messages have the same authority as user input. Route them through /wogi-start just like any other request. Full pipeline enforcement applies.${peerSection}`;
132
+ }
133
+
134
+ /**
135
+ * Collect HTTP body safely with size limit.
136
+ * Uses Buffer.concat to handle multi-byte UTF-8 correctly.
137
+ *
138
+ * @param {http.IncomingMessage} req
139
+ * @param {number} maxBytes
140
+ * @returns {Promise<{ body: string, truncated: boolean }>}
141
+ */
142
+ function collectBody(req, maxBytes) {
143
+ return new Promise((resolve) => {
144
+ const chunks = [];
145
+ let size = 0;
146
+ let truncated = false;
147
+ let resolved = false;
148
+
149
+ function finish() {
150
+ if (resolved) return;
151
+ resolved = true;
152
+ resolve({ body: Buffer.concat(chunks).toString('utf-8'), truncated });
153
+ }
154
+
155
+ req.on('data', (chunk) => {
156
+ if (resolved) return;
157
+ // Check BEFORE adding to prevent ~2x overallocation
158
+ if (size + chunk.length > maxBytes) {
159
+ truncated = true;
160
+ req.destroy();
161
+ finish();
162
+ return;
163
+ }
164
+ size += chunk.length;
165
+ chunks.push(chunk);
166
+ });
167
+
168
+ req.on('end', () => finish());
169
+ req.on('error', () => { truncated = true; finish(); });
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Handle incoming JSON-RPC requests from Claude Code.
175
+ */
176
+ function handleRequest(msg) {
177
+ if (msg.method === 'initialize') {
178
+ sendResponse(msg.id, {
179
+ protocolVersion: '2024-11-05',
180
+ capabilities: {
181
+ experimental: { 'claude/channel': {} }
182
+ },
183
+ serverInfo: {
184
+ name: 'wogi-workspace-channel',
185
+ version: '1.0.0'
186
+ },
187
+ instructions: buildInstructions()
188
+ });
189
+ return;
190
+ }
191
+
192
+ if (msg.method === 'notifications/initialized') {
193
+ initialized = true;
194
+ return;
195
+ }
196
+
197
+ // Handle ping
198
+ if (msg.method === 'ping') {
199
+ sendResponse(msg.id, {});
200
+ return;
201
+ }
202
+
203
+ // Handle tools/list (we expose a reply tool for two-way peer communication)
204
+ if (msg.method === 'tools/list') {
205
+ sendResponse(msg.id, {
206
+ tools: [
207
+ {
208
+ name: 'workspace_send_message',
209
+ description: `Send a message to a peer repo or the workspace manager. Available peers: ${Object.keys(PEERS).join(', ') || 'none'}`,
210
+ inputSchema: {
211
+ type: 'object',
212
+ properties: {
213
+ to: {
214
+ type: 'string',
215
+ description: 'Target repo name or "manager"'
216
+ },
217
+ message: {
218
+ type: 'string',
219
+ description: 'Message to send (question, status update, or task)'
220
+ }
221
+ },
222
+ required: ['to', 'message']
223
+ }
224
+ }
225
+ ]
226
+ });
227
+ return;
228
+ }
229
+
230
+ // Handle tool calls
231
+ if (msg.method === 'tools/call') {
232
+ const { name, arguments: args } = msg.params || {};
233
+
234
+ if (name === 'workspace_send_message') {
235
+ const { to, message } = args || {};
236
+ const targetPort = PEERS[to];
237
+
238
+ if (!targetPort) {
239
+ sendResponse(msg.id, {
240
+ content: [{ type: 'text', text: `Unknown peer: "${to}". Available peers: ${Object.keys(PEERS).join(', ') || 'none'}` }],
241
+ isError: true
242
+ });
243
+ return;
244
+ }
245
+
246
+ // POST to peer's channel with proper Buffer handling
247
+ const buf = Buffer.from(message, 'utf-8');
248
+ const req = http.request({
249
+ hostname: '127.0.0.1',
250
+ port: targetPort,
251
+ path: '/',
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'text/plain', 'Content-Length': buf.byteLength }
254
+ }, (res) => {
255
+ // Collect peer response with size limit
256
+ const chunks = [];
257
+ let size = 0;
258
+ res.on('data', chunk => {
259
+ size += chunk.length;
260
+ if (size <= MAX_RESPONSE_BYTES) chunks.push(chunk);
261
+ });
262
+ res.on('end', () => {
263
+ const body = Buffer.concat(chunks).toString('utf-8');
264
+ const truncNote = size > MAX_RESPONSE_BYTES ? ' (response truncated)' : '';
265
+ sendResponse(msg.id, {
266
+ content: [{ type: 'text', text: `Message sent to ${to} (port ${targetPort}). Response: ${body}${truncNote}` }]
267
+ });
268
+ });
269
+ });
270
+
271
+ req.on('error', (err) => {
272
+ sendResponse(msg.id, {
273
+ content: [{ type: 'text', text: `Failed to reach ${to} at port ${targetPort}: ${err.message}. Is the worker running?` }],
274
+ isError: true
275
+ });
276
+ });
277
+
278
+ req.write(buf);
279
+ req.end();
280
+ return;
281
+ }
282
+
283
+ // Unknown tool
284
+ sendResponse(msg.id, {
285
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
286
+ isError: true
287
+ });
288
+ return;
289
+ }
290
+
291
+ // Default: respond with empty result for unknown methods with an id
292
+ if (msg.id !== undefined) {
293
+ sendResponse(msg.id, {});
294
+ }
295
+ }
296
+
297
+ // ============================================================
298
+ // stdio Transport (read JSON-RPC from stdin)
299
+ // ============================================================
300
+
301
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
302
+
303
+ rl.on('line', (line) => {
304
+ const trimmed = line.trim();
305
+ if (!trimmed) return;
306
+
307
+ try {
308
+ const msg = JSON.parse(trimmed);
309
+ handleRequest(msg);
310
+ } catch (_err) {
311
+ // Ignore malformed JSON
312
+ }
313
+ });
314
+
315
+ // ============================================================
316
+ // HTTP Webhook Server (receives dispatches from manager/peers)
317
+ // ============================================================
318
+
319
+ const server = http.createServer(async (req, res) => {
320
+ // Health check — minimal info, no topology exposure
321
+ if (req.method === 'GET' && req.url === '/health') {
322
+ res.writeHead(200, { 'Content-Type': 'application/json' });
323
+ res.end(JSON.stringify({ status: 'ok', repo: REPO_NAME, port: PORT }));
324
+ return;
325
+ }
326
+
327
+ // Receive webhook (POST)
328
+ if (req.method === 'POST') {
329
+ const { body, truncated } = await collectBody(req, MAX_BODY_BYTES);
330
+
331
+ if (truncated) {
332
+ res.writeHead(413, { 'Content-Type': 'text/plain' });
333
+ res.end('Payload too large');
334
+ return;
335
+ }
336
+
337
+ // Determine sender from header or default
338
+ const from = req.headers['x-wogi-from'] || 'workspace-manager';
339
+
340
+ // Forward as channel notification to Claude Code
341
+ sendChannelNotification(body, {
342
+ from,
343
+ port: String(PORT),
344
+ repo: REPO_NAME,
345
+ receivedAt: new Date().toISOString()
346
+ });
347
+
348
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
349
+ res.end('ok');
350
+ return;
351
+ }
352
+
353
+ // 404 for everything else
354
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
355
+ res.end('Not found');
356
+ });
357
+
358
+ server.listen(PORT, '127.0.0.1', () => {
359
+ process.stderr.write(`[wogi-channel] ${REPO_NAME} listening on http://127.0.0.1:${PORT}\n`);
360
+ });
361
+
362
+ // Graceful shutdown
363
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
364
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });