@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/LICENSE +21 -0
- package/README.md +327 -0
- package/broker.js +520 -0
- package/mcp-server.js +745 -0
- package/package.json +46 -0
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);
|