@skvil/piertotum 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -1,18 +1,16 @@
1
1
  <div align="center">
2
- <img src="logo.png" width="150" alt="Skvil-Piertotum logo" />
2
+ <img src="https://raw.githubusercontent.com/LCGF00/skvil-piertotum/main/logo.png" width="150" alt="Skvil-Piertotum logo" />
3
3
 
4
4
  # Skvil-Piertotum
5
5
 
6
- [![npm version](https://img.shields.io/npm/v/skvil-piertotum)](https://www.npmjs.com/package/skvil-piertotum)
7
- [![Node.js](https://img.shields.io/node/v/skvil-piertotum)](https://nodejs.org)
6
+ [![npm version](https://img.shields.io/npm/v/@skvil/piertotum)](https://www.npmjs.com/package/@skvil/piertotum)
7
+ [![Node.js](https://img.shields.io/node/v/@skvil/piertotum)](https://nodejs.org)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
 
10
10
  **Let your Claude Code instances talk to each other.**
11
11
 
12
12
  Skvil-Piertotum is a lightweight MCP + HTTP broker that connects multiple Claude Code terminals — across projects, machines, WSL, or VMs — so they can exchange messages, share context, and even delegate tasks autonomously.
13
13
 
14
- </div>
15
-
16
14
  ```
17
15
  Claude Code (API project) Claude Code (Frontend project)
18
16
  │ │
@@ -29,6 +27,8 @@ Claude Code (API project) Claude Code (Frontend project)
29
27
  └─────────────┘
30
28
  ```
31
29
 
30
+ </div>
31
+
32
32
  ---
33
33
 
34
34
  ## How it works
@@ -49,6 +49,12 @@ When `AUTO_PROCESS=true`, the MCP server polls for incoming messages and uses **
49
49
 
50
50
  **Requirements:** Node.js 18 or later (`node --version` to check).
51
51
 
52
+ **Option A — via npm (recommended):**
53
+ ```bash
54
+ npm install -g @skvil/piertotum
55
+ ```
56
+
57
+ **Option B — from source:**
52
58
  ```bash
53
59
  git clone https://github.com/LCGF00/skvil-piertotum
54
60
  cd skvil-piertotum
@@ -60,11 +66,17 @@ npm install
60
66
  Run this once, on any machine accessible to your terminals:
61
67
 
62
68
  ```bash
63
- node broker.js
69
+ # if installed globally:
70
+ skvil-piertotum-broker
71
+
72
+ # without installing (npx):
73
+ npx --package=@skvil/piertotum skvil-piertotum-broker
74
+
64
75
  # custom port:
65
- node broker.js 5000
66
- # or via env:
67
- BROKER_PORT=5000 node broker.js
76
+ skvil-piertotum-broker 5000
77
+
78
+ # from source:
79
+ node broker.js
68
80
  ```
69
81
 
70
82
  The broker listens on `0.0.0.0` — reachable from any IP on your network.
@@ -82,7 +94,7 @@ claude mcp add skvil-piertotum \
82
94
  -e AGENT_ID=api \
83
95
  -e AGENT_NAME="API Project" \
84
96
  -e PROJECT_NAME="my-saas" \
85
- -- node /path/to/skvil-piertotum/mcp-server.js
97
+ -- skvil-piertotum-mcp
86
98
 
87
99
  # Terminal 2 — Frontend project
88
100
  claude mcp add skvil-piertotum \
@@ -90,9 +102,12 @@ claude mcp add skvil-piertotum \
90
102
  -e AGENT_ID=front \
91
103
  -e AGENT_NAME="Frontend Project" \
92
104
  -e PROJECT_NAME="my-saas" \
93
- -- node /path/to/skvil-piertotum/mcp-server.js
105
+ -- skvil-piertotum-mcp
94
106
  ```
95
107
 
108
+ > Not installed globally? Replace `skvil-piertotum-mcp` with:
109
+ > `npx --package=@skvil/piertotum skvil-piertotum-mcp`
110
+
96
111
  If everything is on the same machine, use `BROKER_URL=http://localhost:4800`.
97
112
 
98
113
  ### 4. Or configure via `~/.claude.json`
@@ -102,8 +117,7 @@ If everything is on the same machine, use `BROKER_URL=http://localhost:4800`.
102
117
  "mcpServers": {
103
118
  "skvil-piertotum": {
104
119
  "type": "stdio",
105
- "command": "node",
106
- "args": ["/absolute/path/to/skvil-piertotum/mcp-server.js"],
120
+ "command": "skvil-piertotum-mcp",
107
121
  "env": {
108
122
  "BROKER_URL": "http://192.168.1.10:4800",
109
123
  "AGENT_ID": "api",
@@ -265,14 +279,14 @@ Full endpoint reference:
265
279
  ```
266
280
  POST /agents/register Register an agent
267
281
  GET /agents List agents
268
- POST /agents/:id/heartbeat Heartbeat (404 if not registered)
269
- DELETE /agents/:id Deregister agent
282
+ POST /agents/:agentId/heartbeat Heartbeat (404 if not registered)
283
+ DELETE /agents/:agentId Deregister agent
270
284
 
271
285
  POST /messages/send Send to one agent
272
286
  POST /messages/broadcast Send to all agents except sender
273
- GET /messages/:id Read messages (?unread=true, ?limit=N)
274
- POST /messages/:id/ack Mark message IDs as read
275
- DELETE /messages/:id Clear all messages
287
+ GET /messages/:agentId Read messages (?unread=true, ?limit=N)
288
+ POST /messages/:agentId/ack Mark message IDs as read
289
+ DELETE /messages/:agentId Clear all messages
276
290
 
277
291
  POST /context Save context entry
278
292
  GET /context List context keys
@@ -287,7 +301,7 @@ GET /status Broker overview
287
301
  ## Design Notes
288
302
 
289
303
  - **In-memory only** — all state is lost if the broker restarts. Agents re-register automatically on the next heartbeat (within 30s). For persistence, adapt the broker to use SQLite.
290
- - **Resource limits** — max 100 agents, 200 messages per queue (oldest dropped), 1000 context keys, 100 KB per context value.
304
+ - **Resource limits** — max 100 agents, 200 messages per queue (oldest dropped), 1000 context keys, 100 KB per context value, 512 KB per message.
291
305
  - **Stale agent cleanup** — agents that miss 3 heartbeats (90s) are automatically removed.
292
306
  - **Message types** — `text`, `code`, `schema`, `endpoint`, `config`. Used by agents to route and handle responses appropriately.
293
307
  - **Prompt injection protection** — in autonomous mode, incoming message content is wrapped in XML tags with a random nonce before being injected into Claude's context.
package/broker.js CHANGED
@@ -33,7 +33,12 @@ process.on('unhandledRejection', (reason) => {
33
33
  const app = express();
34
34
  app.use(express.json({ limit: '5mb' }));
35
35
 
36
- const PORT = process.env.BROKER_PORT || process.argv[2] || 4800;
36
+ const _rawPort = process.env.BROKER_PORT || process.argv[2] || 4800;
37
+ const PORT = Number(_rawPort);
38
+ if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
39
+ _error(`[ERRO] Porta inválida: "${_rawPort}". Use um número entre 1 e 65535.`);
40
+ process.exit(1);
41
+ }
37
42
 
38
43
  // ══════════════════════════════════════════════
39
44
  // Limites de recursos
@@ -102,7 +107,8 @@ app.use((req, res, next) => {
102
107
  (req.method === 'POST' && req.path.endsWith('/heartbeat'));
103
108
  if (!isPolling) {
104
109
  const ts = new Date().toLocaleTimeString('pt-BR');
105
- console.log(`[${ts}] ${req.method} ${req.path}`);
110
+ const safePath = req.originalUrl.replace(/[\x00-\x1f\x7f]/g, '');
111
+ console.log(`[${ts}] ${req.method} ${safePath}`);
106
112
  }
107
113
  next();
108
114
  });
@@ -116,6 +122,9 @@ app.post('/agents/register', (req, res) => {
116
122
  if (!agentId || !name) {
117
123
  return res.status(400).json({ error: 'agentId e name são obrigatórios' });
118
124
  }
125
+ if (typeof agentId !== 'string' || typeof name !== 'string') {
126
+ return res.status(400).json({ error: 'agentId e name devem ser strings' });
127
+ }
119
128
 
120
129
  if (!agents.has(agentId) && agents.size >= MAX_AGENTS) {
121
130
  return res.status(429).json({ error: `Limite de ${MAX_AGENTS} agentes atingido` });
@@ -190,6 +199,9 @@ app.post('/messages/send', (req, res) => {
190
199
  if (!from || !to || !content) {
191
200
  return res.status(400).json({ error: 'from, to e content são obrigatórios' });
192
201
  }
202
+ if (typeof from !== 'string' || typeof to !== 'string' || typeof content !== 'string') {
203
+ return res.status(400).json({ error: 'from, to e content devem ser strings' });
204
+ }
193
205
 
194
206
  if (!agents.has(from) && from !== 'broker') {
195
207
  return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
@@ -227,6 +239,9 @@ app.post('/messages/broadcast', (req, res) => {
227
239
  if (!from || !content) {
228
240
  return res.status(400).json({ error: 'from e content são obrigatórios' });
229
241
  }
242
+ if (typeof from !== 'string' || typeof content !== 'string') {
243
+ return res.status(400).json({ error: 'from e content devem ser strings' });
244
+ }
230
245
 
231
246
  if (!agents.has(from) && from !== 'broker') {
232
247
  return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
@@ -261,6 +276,9 @@ app.post('/messages/broadcast', (req, res) => {
261
276
  // ?unread=true → apenas não lidas | ?limit=N → máximo N mensagens
262
277
  // Não marca como lidas — use POST /messages/:agentId/ack para confirmar recebimento.
263
278
  app.get('/messages/:agentId', (req, res) => {
279
+ if (!agents.has(req.params.agentId)) {
280
+ return res.status(404).json({ error: `Agente "${req.params.agentId}" não registrado` });
281
+ }
264
282
  const queue = messages.get(req.params.agentId) || [];
265
283
  const unreadOnly = req.query.unread === 'true';
266
284
  const limit = req.query.limit ? Math.max(1, parseInt(req.query.limit, 10) || 50) : null;
@@ -274,6 +292,9 @@ app.get('/messages/:agentId', (req, res) => {
274
292
 
275
293
  // ACK — marca mensagens específicas como lidas
276
294
  app.post('/messages/:agentId/ack', (req, res) => {
295
+ if (!agents.has(req.params.agentId)) {
296
+ return res.status(404).json({ error: `Agente "${req.params.agentId}" não registrado` });
297
+ }
277
298
  const { ids } = req.body;
278
299
  if (!Array.isArray(ids) || ids.length === 0) {
279
300
  return res.status(400).json({ error: 'ids deve ser um array não-vazio de message IDs' });
@@ -365,6 +386,15 @@ app.use((req, res) => {
365
386
  res.status(404).json({ error: `Rota não encontrada: ${req.method} ${req.path}` });
366
387
  });
367
388
 
389
+ // Error handler — retorna JSON em vez de HTML (ex: body JSON malformado)
390
+ app.use((err, req, res, _next) => {
391
+ if (err.type === 'entity.parse.failed') {
392
+ return res.status(400).json({ error: 'JSON inválido no body da requisição' });
393
+ }
394
+ _error(`[ERRO] ${err.stack || err.message}`);
395
+ res.status(err.status || 500).json({ error: 'Erro interno do servidor' });
396
+ });
397
+
368
398
  // ══════════════════════════════════════════════
369
399
  // Console interativo do operador
370
400
  // ══════════════════════════════════════════════
@@ -454,8 +484,12 @@ function startConsole() {
454
484
  });
455
485
 
456
486
  rl.on('close', () => {
457
- _log('\n Broker encerrado.');
458
- process.exit(0);
487
+ // Quando shutdown() chama rl.close(), o httpServer.close() cuida de sair.
488
+ // Só sai aqui se o readline fechou por conta própria (Ctrl+D).
489
+ if (!shuttingDown) {
490
+ _log('\n Broker encerrado.');
491
+ process.exit(0);
492
+ }
459
493
  });
460
494
  }
461
495
 
@@ -505,7 +539,11 @@ httpServer.on('error', (err) => {
505
539
  // Shutdown gracioso (SIGTERM / SIGINT)
506
540
  // ══════════════════════════════════════════════
507
541
 
542
+ let shuttingDown = false;
543
+
508
544
  const shutdown = () => {
545
+ if (shuttingDown) return; // evita dupla execução (SIGTERM + SIGINT rápidos)
546
+ shuttingDown = true;
509
547
  _log('\n 🛑 Broker encerrando...');
510
548
  if (rl) rl.close();
511
549
  httpServer.close(() => {
package/logo.png ADDED
Binary file
package/mcp-server.js CHANGED
@@ -19,6 +19,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
20
  import { z } from 'zod';
21
21
  import os from 'os';
22
+ import { readFileSync } from 'fs';
23
+ import { fileURLToPath } from 'url';
24
+ import { dirname, join } from 'path';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const PKG_VERSION = (() => {
28
+ try { return JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version; }
29
+ catch { return '0.0.0'; }
30
+ })();
22
31
 
23
32
  // ══════════════════════════════════════════════
24
33
  // Validação de configuração na inicialização
@@ -38,7 +47,7 @@ function validateBrokerUrl(raw) {
38
47
  }
39
48
  }
40
49
 
41
- const BROKER_URL = validateBrokerUrl(process.env.BROKER_URL || 'http://localhost:4800');
50
+ const BROKER_URL = validateBrokerUrl(process.env.BROKER_URL || 'http://localhost:4800').replace(/\/+$/, '');
42
51
  const AGENT_ID = process.env.AGENT_ID || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
43
52
  const AGENT_NAME = process.env.AGENT_NAME || `SP-${AGENT_ID}`;
44
53
  const PROJECT_NAME = process.env.PROJECT_NAME || 'unknown';
@@ -99,7 +108,11 @@ async function brokerFetch(path, options = {}) {
99
108
  try { body = await res.json(); } catch { body = {}; }
100
109
  return { error: body.error || `HTTP ${res.status} ${res.statusText}` };
101
110
  }
102
- return await res.json();
111
+ try {
112
+ return await res.json();
113
+ } catch {
114
+ return { error: `Resposta inválida do broker (não é JSON) em ${path}` };
115
+ }
103
116
  } catch (err) {
104
117
  if (err.name === 'TimeoutError') {
105
118
  return { error: `Broker não respondeu em ${FETCH_TIMEOUT_MS / 1000}s` };
@@ -149,7 +162,7 @@ async function register() {
149
162
 
150
163
  const server = new McpServer({
151
164
  name: 'skvil-piertotum',
152
- version: '1.0.0',
165
+ version: PKG_VERSION,
153
166
  description: 'Comunicação entre instâncias do Claude Code via broker central'
154
167
  });
155
168
 
@@ -298,7 +311,10 @@ server.tool(
298
311
  // Marca as mensagens lidas explicitamente (ACK)
299
312
  const ids = result.messages.map(m => m.id).filter(Boolean);
300
313
  if (ids.length > 0) {
301
- await brokerPost(`/messages/${AGENT_ID}/ack`, { ids });
314
+ const ackResult = await brokerPost(`/messages/${AGENT_ID}/ack`, { ids });
315
+ if (ackResult.error) {
316
+ process.stderr.write(`⚠️ ACK falhou: ${ackResult.error}\n`);
317
+ }
302
318
  }
303
319
 
304
320
  const lines = result.messages.map(m =>
@@ -356,7 +372,7 @@ server.tool(
356
372
  key: z.string().describe('Chave do contexto a ler (ex: "api-endpoints")')
357
373
  },
358
374
  async ({ key }) => {
359
- const result = await brokerFetch(`/context/${key}`);
375
+ const result = await brokerFetch(`/context/${encodeURIComponent(key)}`);
360
376
 
361
377
  if (result.error) {
362
378
  return { content: [{ type: 'text', text: `❌ ${result.error}` }] };
@@ -583,7 +599,7 @@ async function processMessage(msg) {
583
599
 
584
600
  const responseText = sampling.content.type === 'text'
585
601
  ? sampling.content.text
586
- : JSON.stringify(sampling.content);
602
+ : `[resposta não-texto do tipo "${sampling.content.type}" — não suportada pelo modo autônomo]`;
587
603
 
588
604
  // Envia resposta de volta ao remetente
589
605
  if (canReply) {
@@ -642,11 +658,17 @@ async function pollAndProcess() {
642
658
 
643
659
  // Processa uma mensagem por vez, em ordem; ACK individual após cada processamento
644
660
  for (const msg of result.messages) {
645
- await processMessage(msg);
646
- if (!autoProcessEnabled) break; // sampling falhou — não ACK nem continua o batch
661
+ try {
662
+ await processMessage(msg);
663
+ } catch (err) {
664
+ // ACK mesmo em erro para evitar poison message loop (retry infinito)
665
+ process.stderr.write(`⚠️ Erro ao processar mensagem ${msg.id}: ${err.message}\n`);
666
+ }
667
+ // ACK sempre — inclusive se processMessage falhou (evita poison loop)
647
668
  if (msg.id) {
648
669
  await brokerPost(`/messages/${AGENT_ID}/ack`, { ids: [msg.id] });
649
670
  }
671
+ if (!autoProcessEnabled) break; // sampling falhou — não continua o batch
650
672
  }
651
673
  } finally {
652
674
  isProcessing = false;
@@ -656,8 +678,10 @@ async function pollAndProcess() {
656
678
  function startAutonomousMode() {
657
679
  if (pollTimer) return; // já rodando
658
680
  process.stderr.write(`🤖 Modo autônomo ativado — polling a cada ${POLL_INTERVAL_MS / 1000}s\n`);
659
- pollAndProcess(); // primeiro poll imediato — não espera o intervalo completo
660
- pollTimer = setInterval(pollAndProcess, POLL_INTERVAL_MS);
681
+ pollAndProcess().catch(err => process.stderr.write(`⚠️ Erro no poll inicial: ${err.message}\n`));
682
+ pollTimer = setInterval(() => {
683
+ pollAndProcess().catch(err => process.stderr.write(`⚠️ Erro no poll: ${err.message}\n`));
684
+ }, POLL_INTERVAL_MS);
661
685
  }
662
686
 
663
687
  // ══════════════════════════════════════════════
@@ -709,7 +733,10 @@ async function main() {
709
733
  }, 30000);
710
734
 
711
735
  // Shutdown gracioso — aguarda processamento em andamento antes de sair
736
+ let shuttingDown = false;
712
737
  const shutdown = async () => {
738
+ if (shuttingDown) return;
739
+ shuttingDown = true;
713
740
  clearInterval(heartbeatTimer);
714
741
  if (pollTimer) clearInterval(pollTimer);
715
742
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skvil/piertotum",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "MCP + HTTP broker for multi-instance Claude Code communication",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,9 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/LCGF00/skvil-piertotum.git"
12
12
  },
13
+ "bugs": {
14
+ "url": "https://github.com/LCGF00/skvil-piertotum/issues"
15
+ },
13
16
  "keywords": [
14
17
  "mcp",
15
18
  "claude",
@@ -28,7 +31,8 @@
28
31
  "broker.js",
29
32
  "mcp-server.js",
30
33
  "README.md",
31
- "LICENSE"
34
+ "LICENSE",
35
+ "logo.png"
32
36
  ],
33
37
  "bin": {
34
38
  "skvil-piertotum-broker": "broker.js",