@skvil/piertotum 1.0.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.
package/broker.js ADDED
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Skvil-Piertotum Broker — Servidor HTTP central
5
+ *
6
+ * Este servidor roda na sua rede e gerencia a comunicação
7
+ * entre múltiplas instâncias do Claude Code via MCP.
8
+ *
9
+ * Uso: node broker.js [porta]
10
+ * Padrão: porta 4800
11
+ */
12
+
13
+ import express from 'express';
14
+ import readline from 'readline';
15
+
16
+ // ══════════════════════════════════════════════
17
+ // Handlers globais de erro
18
+ // uncaughtException: loga o stack completo e sai — continuar após
19
+ // uma exceção não capturada deixa o processo em estado indefinido.
20
+ // Deixe o process manager (systemd, Docker, PM2) reiniciar.
21
+ // ══════════════════════════════════════════════
22
+
23
+ process.on('uncaughtException', (err) => {
24
+ console.error(`[FATAL] Exceção não capturada:\n${err.stack}`);
25
+ process.exit(1);
26
+ });
27
+
28
+ process.on('unhandledRejection', (reason) => {
29
+ console.error(`[FATAL] Promise rejeitada sem handler: ${reason instanceof Error ? reason.stack : reason}`);
30
+ process.exit(1);
31
+ });
32
+
33
+ const app = express();
34
+ app.use(express.json({ limit: '5mb' }));
35
+
36
+ const PORT = process.env.BROKER_PORT || process.argv[2] || 4800;
37
+
38
+ // ══════════════════════════════════════════════
39
+ // Limites de recursos
40
+ // ══════════════════════════════════════════════
41
+
42
+ const MAX_MESSAGES_PER_AGENT = 200;
43
+ const MAX_AGENTS = 100;
44
+ const MAX_CONTEXT_KEYS = 1000;
45
+ const MAX_CONTEXT_VALUE_SIZE = 100 * 1024; // 100 KB
46
+ const MAX_MESSAGE_CONTENT_SIZE = 512 * 1024; // 512 KB por mensagem
47
+ const STALE_AGENT_THRESHOLD_MS = 90_000; // 3 heartbeats perdidos (heartbeat = 30s)
48
+
49
+ // ══════════════════════════════════════════════
50
+ // Estado em memória
51
+ // ══════════════════════════════════════════════
52
+
53
+ const agents = new Map(); // agentId -> { name, project, path, registeredAt, lastSeen }
54
+ const messages = new Map(); // agentId -> [ { id, from, fromName, content, type, timestamp, read } ]
55
+ const sharedContext = new Map(); // key -> { value, setBy, setByName, timestamp }
56
+
57
+ // ══════════════════════════════════════════════
58
+ // Console interativo — intercepta console.log/error/warn
59
+ // para não sobrescrever o prompt do readline
60
+ // ══════════════════════════════════════════════
61
+
62
+ let rl = null;
63
+ const _log = console.log.bind(console);
64
+ const _error = console.error.bind(console);
65
+ const _warn = console.warn.bind(console);
66
+
67
+ const _rlWrite = (fn, args) => {
68
+ if (rl) process.stdout.write('\r\x1b[K');
69
+ fn(...args);
70
+ if (rl) rl.prompt(true);
71
+ };
72
+
73
+ console.log = (...args) => _rlWrite(_log, args);
74
+ console.error = (...args) => _rlWrite(_error, args);
75
+ console.warn = (...args) => _rlWrite(_warn, args);
76
+
77
+ // ══════════════════════════════════════════════
78
+ // Helper: enfileira mensagem com cap automático
79
+ // Usado por todos os paths de envio para garantir
80
+ // que o limite de 200 mensagens seja sempre aplicado.
81
+ // ══════════════════════════════════════════════
82
+
83
+ function enqueue(agentId, msg) {
84
+ if (!messages.has(agentId)) messages.set(agentId, []);
85
+ const queue = messages.get(agentId);
86
+ queue.push(msg);
87
+ if (queue.length > MAX_MESSAGES_PER_AGENT) {
88
+ const dropped = queue.length - MAX_MESSAGES_PER_AGENT;
89
+ queue.splice(0, dropped);
90
+ console.log(` ⚠️ Fila de "${agentId}" cheia — ${dropped} mensagem(ns) antiga(s) descartada(s)`);
91
+ }
92
+ }
93
+
94
+ // ══════════════════════════════════════════════
95
+ // Middleware de log
96
+ // ══════════════════════════════════════════════
97
+
98
+ app.use((req, res, next) => {
99
+ // Silencia polling de alta frequência (heartbeats e leitura de mensagens)
100
+ const isPolling =
101
+ (req.method === 'GET' && req.path.startsWith('/messages/')) ||
102
+ (req.method === 'POST' && req.path.endsWith('/heartbeat'));
103
+ if (!isPolling) {
104
+ const ts = new Date().toLocaleTimeString('pt-BR');
105
+ console.log(`[${ts}] ${req.method} ${req.path}`);
106
+ }
107
+ next();
108
+ });
109
+
110
+ // ══════════════════════════════════════════════
111
+ // Rotas: Registro de Agentes
112
+ // ══════════════════════════════════════════════
113
+
114
+ app.post('/agents/register', (req, res) => {
115
+ const { agentId, name, project, path } = req.body;
116
+ if (!agentId || !name) {
117
+ return res.status(400).json({ error: 'agentId e name são obrigatórios' });
118
+ }
119
+
120
+ if (!agents.has(agentId) && agents.size >= MAX_AGENTS) {
121
+ return res.status(429).json({ error: `Limite de ${MAX_AGENTS} agentes atingido` });
122
+ }
123
+
124
+ agents.set(agentId, {
125
+ name,
126
+ project: project || 'unknown',
127
+ path: path || '',
128
+ registeredAt: new Date().toISOString(),
129
+ lastSeen: new Date().toISOString()
130
+ });
131
+
132
+ if (!messages.has(agentId)) {
133
+ messages.set(agentId, []);
134
+ }
135
+
136
+ console.log(` ✅ Agente registrado: ${name} (${agentId}) — projeto: ${project || 'N/A'}`);
137
+ res.json({ ok: true, agentId, totalAgents: agents.size });
138
+ });
139
+
140
+ app.get('/agents', (req, res) => {
141
+ const list = [];
142
+ for (const [id, info] of agents) {
143
+ list.push({ agentId: id, ...info });
144
+ }
145
+ res.json({ agents: list });
146
+ });
147
+
148
+ app.post('/agents/:agentId/heartbeat', (req, res) => {
149
+ const agent = agents.get(req.params.agentId);
150
+ if (!agent) {
151
+ return res.status(404).json({ error: 'Agente não registrado' });
152
+ }
153
+ agent.lastSeen = new Date().toISOString();
154
+ res.json({ ok: true });
155
+ });
156
+
157
+ app.delete('/agents/:agentId', (req, res) => {
158
+ const existed = agents.has(req.params.agentId);
159
+ agents.delete(req.params.agentId);
160
+ messages.delete(req.params.agentId);
161
+ if (existed) {
162
+ console.log(` ❌ Agente removido: ${req.params.agentId}`);
163
+ }
164
+ res.json({ ok: true });
165
+ });
166
+
167
+ // ══════════════════════════════════════════════
168
+ // Rotas: Limpeza de mensagens
169
+ // ══════════════════════════════════════════════
170
+
171
+ app.delete('/messages/:agentId', (req, res) => {
172
+ const queue = messages.get(req.params.agentId);
173
+ if (!queue) {
174
+ return res.status(404).json({ error: 'Agente não encontrado' });
175
+ }
176
+ const cleared = queue.length;
177
+ queue.length = 0;
178
+ console.log(` 🗑️ Mensagens limpas: ${req.params.agentId} (${cleared} removida(s))`);
179
+ res.json({ ok: true, cleared });
180
+ });
181
+
182
+ // ══════════════════════════════════════════════
183
+ // Rotas: Mensagens diretas
184
+ // ══════════════════════════════════════════════
185
+
186
+ const VALID_MSG_TYPES = new Set(['text', 'code', 'schema', 'endpoint', 'config']);
187
+
188
+ app.post('/messages/send', (req, res) => {
189
+ const { from, to, content, type } = req.body;
190
+ if (!from || !to || !content) {
191
+ return res.status(400).json({ error: 'from, to e content são obrigatórios' });
192
+ }
193
+
194
+ if (!agents.has(from) && from !== 'broker') {
195
+ return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
196
+ }
197
+
198
+ if (!agents.has(to)) {
199
+ return res.status(404).json({ error: `Agente "${to}" não encontrado` });
200
+ }
201
+
202
+ if (Buffer.byteLength(content, 'utf8') > MAX_MESSAGE_CONTENT_SIZE) {
203
+ return res.status(413).json({ error: `Conteúdo excede o limite de ${MAX_MESSAGE_CONTENT_SIZE / 1024}KB por mensagem` });
204
+ }
205
+
206
+ const msgType = VALID_MSG_TYPES.has(type) ? type : 'text';
207
+
208
+ const msg = {
209
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
210
+ from,
211
+ fromName: agents.get(from)?.name || from,
212
+ content,
213
+ type: msgType,
214
+ timestamp: new Date().toISOString(),
215
+ read: false
216
+ };
217
+
218
+ enqueue(to, msg);
219
+ const preview = content.length > 80 ? content.slice(0, 80) + '...' : content;
220
+ console.log(` 💬 ${msg.fromName} → ${agents.get(to)?.name || to}: ${preview}`);
221
+ res.json({ ok: true, messageId: msg.id });
222
+ });
223
+
224
+ // Broadcast — enviar para todos os agentes (exceto o remetente)
225
+ app.post('/messages/broadcast', (req, res) => {
226
+ const { from, content, type } = req.body;
227
+ if (!from || !content) {
228
+ return res.status(400).json({ error: 'from e content são obrigatórios' });
229
+ }
230
+
231
+ if (!agents.has(from) && from !== 'broker') {
232
+ return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
233
+ }
234
+
235
+ if (Buffer.byteLength(content, 'utf8') > MAX_MESSAGE_CONTENT_SIZE) {
236
+ return res.status(413).json({ error: `Conteúdo excede o limite de ${MAX_MESSAGE_CONTENT_SIZE / 1024}KB por mensagem` });
237
+ }
238
+
239
+ const msgType = VALID_MSG_TYPES.has(type) ? type : 'text';
240
+ let count = 0;
241
+
242
+ for (const [agentId] of agents) {
243
+ if (agentId === from) continue;
244
+ enqueue(agentId, {
245
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
246
+ from,
247
+ fromName: agents.get(from)?.name || from,
248
+ content,
249
+ type: msgType,
250
+ timestamp: new Date().toISOString(),
251
+ read: false
252
+ });
253
+ count++;
254
+ }
255
+
256
+ console.log(` 📢 Broadcast de ${agents.get(from)?.name || from} para ${count} agentes`);
257
+ res.json({ ok: true, sentTo: count });
258
+ });
259
+
260
+ // Ler mensagens de um agente
261
+ // ?unread=true → apenas não lidas | ?limit=N → máximo N mensagens
262
+ // Não marca como lidas — use POST /messages/:agentId/ack para confirmar recebimento.
263
+ app.get('/messages/:agentId', (req, res) => {
264
+ const queue = messages.get(req.params.agentId) || [];
265
+ const unreadOnly = req.query.unread === 'true';
266
+ const limit = req.query.limit ? Math.max(1, parseInt(req.query.limit, 10) || 50) : null;
267
+
268
+ const filtered = unreadOnly ? queue.filter(m => !m.read) : [...queue];
269
+ const hasMore = limit !== null && filtered.length > limit;
270
+ const result = limit !== null ? filtered.slice(0, limit) : filtered;
271
+
272
+ res.json({ messages: result, total: result.length, hasMore });
273
+ });
274
+
275
+ // ACK — marca mensagens específicas como lidas
276
+ app.post('/messages/:agentId/ack', (req, res) => {
277
+ const { ids } = req.body;
278
+ if (!Array.isArray(ids) || ids.length === 0) {
279
+ return res.status(400).json({ error: 'ids deve ser um array não-vazio de message IDs' });
280
+ }
281
+ const queue = messages.get(req.params.agentId) || [];
282
+ const idSet = new Set(ids);
283
+ let acked = 0;
284
+ for (const msg of queue) {
285
+ if (idSet.has(msg.id)) { msg.read = true; acked++; }
286
+ }
287
+ res.json({ ok: true, acked });
288
+ });
289
+
290
+ // ══════════════════════════════════════════════
291
+ // Rotas: Contexto Compartilhado
292
+ // ══════════════════════════════════════════════
293
+
294
+ app.post('/context', (req, res) => {
295
+ const { key, value, setBy } = req.body;
296
+ if (!key || value === undefined || value === null) {
297
+ return res.status(400).json({ error: 'key e value são obrigatórios' });
298
+ }
299
+
300
+ if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_CONTEXT_VALUE_SIZE) {
301
+ return res.status(413).json({ error: `Valor excede o limite de ${MAX_CONTEXT_VALUE_SIZE / 1024}KB` });
302
+ }
303
+
304
+ if (!sharedContext.has(key) && sharedContext.size >= MAX_CONTEXT_KEYS) {
305
+ return res.status(429).json({ error: `Limite de ${MAX_CONTEXT_KEYS} chaves de contexto atingido` });
306
+ }
307
+
308
+ sharedContext.set(key, {
309
+ value,
310
+ setBy: setBy || 'unknown',
311
+ setByName: agents.get(setBy)?.name || setBy,
312
+ timestamp: new Date().toISOString()
313
+ });
314
+
315
+ console.log(` 📦 Contexto salvo: "${key}" por ${agents.get(setBy)?.name || setBy}`);
316
+ res.json({ ok: true, key });
317
+ });
318
+
319
+ app.get('/context/:key', (req, res) => {
320
+ const ctx = sharedContext.get(req.params.key);
321
+ if (!ctx) {
322
+ return res.status(404).json({ error: `Contexto "${req.params.key}" não encontrado` });
323
+ }
324
+ res.json(ctx);
325
+ });
326
+
327
+ app.get('/context', (req, res) => {
328
+ const keys = [];
329
+ for (const [key, info] of sharedContext) {
330
+ keys.push({ key, setBy: info.setByName, timestamp: info.timestamp });
331
+ }
332
+ res.json({ contexts: keys });
333
+ });
334
+
335
+ app.delete('/context/:key', (req, res) => {
336
+ sharedContext.delete(req.params.key);
337
+ res.json({ ok: true });
338
+ });
339
+
340
+ // ══════════════════════════════════════════════
341
+ // Rota: Status geral
342
+ // ══════════════════════════════════════════════
343
+
344
+ app.get('/status', (req, res) => {
345
+ const agentList = [];
346
+ for (const [id, info] of agents) {
347
+ const unread = (messages.get(id) || []).filter(m => !m.read).length;
348
+ agentList.push({ agentId: id, ...info, unreadMessages: unread });
349
+ }
350
+
351
+ res.json({
352
+ broker: 'skvil-piertotum',
353
+ uptime: process.uptime(),
354
+ agents: agentList,
355
+ totalAgents: agents.size,
356
+ totalContextKeys: sharedContext.size
357
+ });
358
+ });
359
+
360
+ // ══════════════════════════════════════════════
361
+ // 404 catch-all — retorna JSON em vez de HTML
362
+ // ══════════════════════════════════════════════
363
+
364
+ app.use((req, res) => {
365
+ res.status(404).json({ error: `Rota não encontrada: ${req.method} ${req.path}` });
366
+ });
367
+
368
+ // ══════════════════════════════════════════════
369
+ // Console interativo do operador
370
+ // ══════════════════════════════════════════════
371
+
372
+ function pushToAgent(agentId, content, type = 'text') {
373
+ enqueue(agentId, {
374
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
375
+ from: 'broker',
376
+ fromName: 'Operador',
377
+ content,
378
+ type,
379
+ timestamp: new Date().toISOString(),
380
+ read: false
381
+ });
382
+ }
383
+
384
+ function startConsole() {
385
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
386
+ rl.setPrompt('broker> ');
387
+ rl.prompt();
388
+
389
+ rl.on('line', (line) => {
390
+ const input = line.trim();
391
+ if (!input) { rl.prompt(); return; }
392
+
393
+ // /help
394
+ if (input === '/help') {
395
+ _log(' Comandos disponíveis:');
396
+ _log(' /agents — lista agentes conectados');
397
+ _log(' /help — esta ajuda');
398
+ _log(' @<id> <mensagem> — envia para um agente específico');
399
+ _log(' <mensagem> — broadcast para todos os agentes');
400
+ rl.prompt();
401
+ return;
402
+ }
403
+
404
+ // /agents
405
+ if (input === '/agents') {
406
+ if (agents.size === 0) {
407
+ _log(' Nenhum agente conectado.');
408
+ } else {
409
+ for (const [id, info] of agents) {
410
+ _log(` • ${info.name} (${id}) — ${info.project}`);
411
+ }
412
+ }
413
+ rl.prompt();
414
+ return;
415
+ }
416
+
417
+ // @agentId mensagem
418
+ if (input.startsWith('@')) {
419
+ const spaceIdx = input.indexOf(' ');
420
+ if (spaceIdx === -1) {
421
+ _log(' Uso: @<id> <mensagem>');
422
+ rl.prompt();
423
+ return;
424
+ }
425
+ const targetId = input.slice(1, spaceIdx);
426
+ const content = input.slice(spaceIdx + 1).trim();
427
+ if (!content) {
428
+ _log(' Mensagem vazia.');
429
+ rl.prompt();
430
+ return;
431
+ }
432
+ if (!agents.has(targetId)) {
433
+ _log(` ❌ Agente "${targetId}" não encontrado. Use /agents para listar.`);
434
+ rl.prompt();
435
+ return;
436
+ }
437
+ pushToAgent(targetId, content);
438
+ _log(` 💬 Operador → ${agents.get(targetId).name}: ${content}`);
439
+ rl.prompt();
440
+ return;
441
+ }
442
+
443
+ // broadcast
444
+ if (agents.size === 0) {
445
+ _log(' ⚠️ Nenhum agente conectado.');
446
+ rl.prompt();
447
+ return;
448
+ }
449
+ for (const [agentId] of agents) {
450
+ pushToAgent(agentId, input);
451
+ }
452
+ _log(` 📢 Operador → ${agents.size} agente(s): ${input}`);
453
+ rl.prompt();
454
+ });
455
+
456
+ rl.on('close', () => {
457
+ _log('\n Broker encerrado.');
458
+ process.exit(0);
459
+ });
460
+ }
461
+
462
+ // ══════════════════════════════════════════════
463
+ // Start
464
+ // ══════════════════════════════════════════════
465
+
466
+ const httpServer = app.listen(PORT, '0.0.0.0', () => {
467
+ console.log('');
468
+ console.log(` 🤖 Skvil-Piertotum Broker — Rodando!`);
469
+ console.log(` Endereço : http://0.0.0.0:${PORT}`);
470
+ console.log(` Status : http://localhost:${PORT}/status`);
471
+ console.log(` Configure: BROKER_URL=http://<seu-ip>:${PORT}`);
472
+ console.log('');
473
+ console.log(` Digite uma mensagem e pressione Enter para fazer broadcast.`);
474
+ console.log(` Use @<id> <mensagem> para falar com um agente específico.`);
475
+ console.log(` /agents lista os conectados. /help para ajuda completa.`);
476
+ console.log('');
477
+
478
+ // Reaper de agentes zumbis — remove agentes que pararam de enviar heartbeat
479
+ setInterval(() => {
480
+ const now = Date.now();
481
+ for (const [id, info] of agents) {
482
+ if (now - new Date(info.lastSeen).getTime() > STALE_AGENT_THRESHOLD_MS) {
483
+ agents.delete(id);
484
+ messages.delete(id);
485
+ console.log(` 🕒 Agente removido por inatividade: ${info.name} (${id})`);
486
+ }
487
+ }
488
+ }, 30_000);
489
+
490
+ startConsole();
491
+ });
492
+
493
+ httpServer.on('error', (err) => {
494
+ if (err.code === 'EADDRINUSE') {
495
+ _error(`[ERRO] Porta ${PORT} já está em uso. Tente outra: node broker.js 5000`);
496
+ } else if (err.code === 'EACCES') {
497
+ _error(`[ERRO] Sem permissão para usar a porta ${PORT}.`);
498
+ } else {
499
+ _error(`[ERRO] Falha ao iniciar o servidor: ${err.message}`);
500
+ }
501
+ process.exit(1);
502
+ });
503
+
504
+ // ══════════════════════════════════════════════
505
+ // Shutdown gracioso (SIGTERM / SIGINT)
506
+ // ══════════════════════════════════════════════
507
+
508
+ const shutdown = () => {
509
+ _log('\n 🛑 Broker encerrando...');
510
+ if (rl) rl.close();
511
+ httpServer.close(() => {
512
+ _log(' Broker encerrado.');
513
+ process.exit(0);
514
+ });
515
+ // Força saída se o servidor não fechar em 5s (conexões keep-alive pendentes)
516
+ setTimeout(() => process.exit(1), 5000).unref();
517
+ };
518
+
519
+ process.on('SIGTERM', shutdown);
520
+ process.on('SIGINT', shutdown);