kitty-hive 0.2.0 → 0.3.1

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/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { startServer, setLogLevel } from './server.js';
3
- import { initDB, addPeer, listPeers, removePeer, updatePeerExposed, getPeerByName } from './db.js';
3
+ import { initDB, addPeer, listPeers, removePeer, updatePeerExposed, getPeerByName, setPeerNodeName, setPeerStatus, touchPeer, createPendingInvite, cleanupExpiredInvites } from './db.js';
4
+ import { pingPeer } from './federation-heartbeat.js';
4
5
  import { generateToken } from './utils.js';
5
6
  import { writeFileSync, existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
6
- import { join, dirname } from 'node:path';
7
+ import { join, dirname, basename, delimiter } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
- import { homedir } from 'node:os';
9
+ import { homedir, hostname } from 'node:os';
9
10
  import { createInterface } from 'node:readline';
11
+ import { execSync } from 'node:child_process';
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = dirname(__filename);
12
14
  const args = process.argv.slice(2);
@@ -45,81 +47,260 @@ async function cmdServe() {
45
47
  setLogLevel('warn');
46
48
  await startServer(port, dbPath);
47
49
  }
50
+ const INIT_TOOLS = ['claude', 'cursor', 'vscode'];
51
+ const ALL_INIT_TARGETS = ['claude', 'cursor', 'vscode', 'antigravity'];
52
+ function findNpx() {
53
+ const probe = process.platform === 'win32' ? 'where npx' : 'command -v npx';
54
+ try {
55
+ const out = execSync(probe, { encoding: 'utf8' }).trim().split(/\r?\n/)[0];
56
+ return out || 'npx';
57
+ }
58
+ catch {
59
+ return 'npx';
60
+ }
61
+ }
62
+ function readJson(path) {
63
+ if (!existsSync(path))
64
+ return {};
65
+ try {
66
+ return JSON.parse(readFileSync(path, 'utf8'));
67
+ }
68
+ catch {
69
+ return {};
70
+ }
71
+ }
72
+ function writeJson(path, data) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
75
+ }
76
+ function writeForTool(tool, port) {
77
+ const url = `http://localhost:${port}/mcp`;
78
+ const cwd = process.cwd();
79
+ if (tool === 'claude') {
80
+ const p = join(cwd, '.mcp.json');
81
+ const data = readJson(p);
82
+ if (!data.mcpServers)
83
+ data.mcpServers = {};
84
+ data.mcpServers['hive'] = { url };
85
+ delete data.mcpServers['hive-channel'];
86
+ writeJson(p, data);
87
+ return p;
88
+ }
89
+ if (tool === 'cursor') {
90
+ const p = join(cwd, '.cursor', 'mcp.json');
91
+ const data = readJson(p);
92
+ if (!data.mcpServers)
93
+ data.mcpServers = {};
94
+ data.mcpServers['hive'] = { url };
95
+ writeJson(p, data);
96
+ return p;
97
+ }
98
+ if (tool === 'vscode') {
99
+ const p = join(cwd, '.vscode', 'mcp.json');
100
+ const data = readJson(p);
101
+ if (!data.servers)
102
+ data.servers = {};
103
+ data.servers['hive'] = { type: 'http', url };
104
+ writeJson(p, data);
105
+ return p;
106
+ }
107
+ throw new Error(`Unknown tool: ${tool}`);
108
+ }
109
+ function antigravitySnippet(port) {
110
+ // Antigravity has no public on-disk config path — users edit via
111
+ // "..." → MCP Store → Manage MCP Servers → View raw config.
112
+ // It also doesn't speak streamable HTTP directly, so we route through a stdio→HTTP adapter.
113
+ const url = `http://localhost:${port}/mcp`;
114
+ // Pre-seed PATH so the GUI app can find npx even when launched without a shell.
115
+ const pathDirs = process.platform === 'win32'
116
+ ? [
117
+ process.env.SystemRoot ? join(process.env.SystemRoot, 'System32') : 'C:\\Windows\\System32',
118
+ process.env.APPDATA ? join(process.env.APPDATA, 'npm') : '',
119
+ ].filter(Boolean)
120
+ : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
121
+ return JSON.stringify({
122
+ mcpServers: {
123
+ hive: {
124
+ command: findNpx(),
125
+ args: ['-y', '@pyroprompts/mcp-stdio-to-streamable-http-adapter'],
126
+ env: {
127
+ PATH: pathDirs.join(delimiter),
128
+ URI: url,
129
+ },
130
+ },
131
+ },
132
+ }, null, 2);
133
+ }
134
+ function showInitUsage() {
135
+ console.log('🐝 kitty-hive init — write MCP config for an IDE\n');
136
+ console.log('Usage:');
137
+ console.log(' kitty-hive init <tool> [--port 4123]\n');
138
+ console.log('Tools:');
139
+ console.log(' claude .mcp.json (Claude Code — prefer the plugin instead)');
140
+ console.log(' cursor .cursor/mcp.json');
141
+ console.log(' vscode .vscode/mcp.json (VS Code Copilot)');
142
+ console.log(' antigravity prints snippet to paste via MCP Store UI');
143
+ console.log(' all run all of the above');
144
+ }
48
145
  async function cmdInit() {
49
- console.log('🐝 kitty-hive init — configure HTTP MCP for non-Claude-Code clients\n');
50
- console.log(' (Claude Code users: install the kitty-hive plugin instead)\n');
146
+ const tool = args[1];
147
+ if (!tool || tool.startsWith('-')) {
148
+ showInitUsage();
149
+ process.exit(1);
150
+ }
151
+ const known = ALL_INIT_TARGETS;
152
+ let targets;
153
+ if (tool === 'all') {
154
+ targets = [...ALL_INIT_TARGETS];
155
+ }
156
+ else if (known.includes(tool)) {
157
+ targets = [tool];
158
+ }
159
+ else {
160
+ console.log(`Unknown tool: "${tool}"`);
161
+ showInitUsage();
162
+ process.exit(1);
163
+ }
51
164
  let port = 4123;
52
- let explicitPort = false;
53
- for (let i = 1; i < args.length; i++) {
165
+ for (let i = 2; i < args.length; i++) {
54
166
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
55
- port = parseInt(args[i + 1], 10);
56
- explicitPort = true;
167
+ port = parseInt(args[i + 1], 10) || 4123;
57
168
  i++;
58
169
  }
59
170
  }
60
- if (!explicitPort) {
61
- const p = await ask('Hive server port', '4123');
62
- port = parseInt(p, 10) || 4123;
63
- }
64
- const mcpJsonPath = join(process.cwd(), '.mcp.json');
65
- let existing = {};
66
- if (existsSync(mcpJsonPath)) {
67
- try {
68
- existing = JSON.parse(readFileSync(mcpJsonPath, 'utf8'));
171
+ console.log(`🐝 Configuring hive → http://localhost:${port}/mcp\n`);
172
+ for (const t of targets) {
173
+ if (t === 'antigravity') {
174
+ console.log(` antigravity (no on-disk path — paste this snippet)`);
175
+ console.log(` Open: "..." MCP Store → Manage MCP Servers → View raw config`);
176
+ console.log(` Merge "hive" into mcpServers:\n`);
177
+ const snippet = antigravitySnippet(port).split('\n').map(l => ' ' + l).join('\n');
178
+ console.log(snippet);
179
+ console.log('');
180
+ }
181
+ else {
182
+ const path = writeForTool(t, port);
183
+ console.log(` ${t.padEnd(12)} ${path}`);
69
184
  }
70
- catch { /* ignore */ }
71
185
  }
72
- if (!existing.mcpServers)
73
- existing.mcpServers = {};
74
- existing.mcpServers['hive'] = {
75
- url: `http://localhost:${port}/mcp`,
76
- };
77
- delete existing.mcpServers['hive-channel'];
78
- writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + '\n');
79
- console.log(`🐝 Configured`);
80
- console.log(` Server: http://localhost:${port}/mcp`);
81
- console.log(` Mode: HTTP MCP (for Antigravity, Cursor, VS Code, etc.)`);
82
- console.log(`\n Agent registers via hive.start when first used.`);
186
+ console.log(`\n Agent registers via hive.start when first used.`);
187
+ }
188
+ function relativeTime(iso) {
189
+ if (!iso)
190
+ return '-';
191
+ const then = new Date(iso).getTime();
192
+ if (Number.isNaN(then))
193
+ return iso;
194
+ const diff = Math.max(0, Date.now() - then);
195
+ const s = Math.floor(diff / 1000);
196
+ if (s < 5)
197
+ return 'now';
198
+ if (s < 60)
199
+ return `${s}s ago`;
200
+ const m = Math.floor(s / 60);
201
+ if (m < 60)
202
+ return `${m}m ago`;
203
+ const h = Math.floor(m / 60);
204
+ if (h < 24)
205
+ return `${h}h ago`;
206
+ const d = Math.floor(h / 24);
207
+ return `${d}d ago`;
208
+ }
209
+ function visualWidth(s) {
210
+ let w = 0;
211
+ for (const c of s) {
212
+ const cp = c.codePointAt(0);
213
+ const wide = (cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) ||
214
+ (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
215
+ (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) ||
216
+ (cp >= 0xAC00 && cp <= 0xD7A3) || (cp >= 0xF900 && cp <= 0xFAFF) ||
217
+ (cp >= 0xFE30 && cp <= 0xFE4F) || (cp >= 0xFF00 && cp <= 0xFF60) ||
218
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x20000 && cp <= 0x3FFFD);
219
+ w += wide ? 2 : 1;
220
+ }
221
+ return w;
83
222
  }
223
+ function padCell(s, width) {
224
+ return s + ' '.repeat(Math.max(0, width - visualWidth(s)));
225
+ }
226
+ function renderTable(headers, rows, indent = '') {
227
+ const widths = headers.map((h, i) => Math.max(visualWidth(h), ...rows.map(r => visualWidth(r[i] ?? ''))));
228
+ const fmt = (cells) => indent + cells.map((c, i) => padCell(c ?? '', widths[i])).join(' ').trimEnd();
229
+ const sep = indent + widths.map(w => '─'.repeat(w)).join(' ');
230
+ return [fmt(headers), sep, ...rows.map(fmt)].join('\n');
231
+ }
232
+ function agentRows(db, agents) {
233
+ const teamStmt = db.prepare(`
234
+ SELECT t.name, tm.nickname FROM teams t
235
+ JOIN team_members tm ON tm.team_id = t.id
236
+ WHERE tm.agent_id = ? AND t.closed_at IS NULL
237
+ ORDER BY t.name
238
+ `);
239
+ return agents.map(a => {
240
+ const memberTeams = teamStmt.all(a.id);
241
+ const teamLabels = memberTeams.map(t => t.nickname ? `${t.name}:${t.nickname}` : t.name).join(', ') || '-';
242
+ return [
243
+ a.id.slice(0, 12),
244
+ a.display_name,
245
+ a.tool || '-',
246
+ a.status,
247
+ a.roles || '-',
248
+ teamLabels,
249
+ relativeTime(a.last_seen),
250
+ ];
251
+ });
252
+ }
253
+ const AGENT_HEADERS = ['ID', 'NAME', 'TOOL', 'STATUS', 'ROLES', 'TEAMS', 'LAST SEEN'];
84
254
  async function cmdStatus() {
85
255
  const { port, dbPath } = parseFlags(1);
86
256
  const url = `http://localhost:${port}/mcp`;
87
- // Check server
257
+ const nodeName = getNodeConfig().name || hostname().split('.')[0];
88
258
  try {
89
- const res = await fetch(url, { method: 'GET' });
259
+ await fetch(url, { method: 'GET' });
90
260
  console.log(`🐝 Server: http://localhost:${port}/mcp (online)`);
91
261
  }
92
262
  catch {
93
263
  console.log(`❌ Server: http://localhost:${port}/mcp (offline)`);
94
264
  process.exit(1);
95
265
  }
96
- // Check DB
266
+ console.log(` Node: ${nodeName}`);
97
267
  try {
98
268
  const db = initDB(dbPath);
99
- const agents = db.prepare('SELECT id, display_name, status, last_seen FROM agents ORDER BY last_seen DESC').all();
100
- const teams = db.prepare("SELECT id, name FROM teams WHERE closed_at IS NULL").all();
269
+ const agents = db.prepare("SELECT id, display_name, tool, roles, status, last_seen FROM agents WHERE origin_peer = '' ORDER BY last_seen DESC").all();
270
+ const remotes = db.prepare("SELECT id, display_name, tool, roles, status, last_seen, origin_peer FROM agents WHERE origin_peer != '' ORDER BY last_seen DESC").all();
271
+ const teams = db.prepare("SELECT id, name FROM teams WHERE closed_at IS NULL ORDER BY name").all();
101
272
  const tasks = db.prepare("SELECT count(*) as cnt FROM tasks WHERE status NOT IN ('completed','failed','canceled')").get();
102
- console.log(`\n📊 Database: ${dbPath || '~/.kitty-hive/hive.db'}`);
103
- console.log(` Teams: ${teams.length} Active tasks: ${tasks.cnt}`);
104
- console.log(`\n👥 Agents (${agents.length}):`);
105
- for (const a of agents) {
106
- const memberTeams = db.prepare(`
107
- SELECT t.name, tm.nickname FROM teams t
108
- JOIN team_members tm ON tm.team_id = t.id
109
- WHERE tm.agent_id = ? AND t.closed_at IS NULL
110
- `).all(a.id);
111
- const teamLabels = memberTeams.map((t) => t.nickname ? `${t.name}:${t.nickname}` : t.name).join(', ');
112
- console.log(` ${a.display_name} [${a.id.slice(0, 8)}…] (${a.status}) — ${teamLabels || 'no teams'}`);
273
+ const peers = listPeers();
274
+ console.log(`📊 Database: ${dbPath || '~/.kitty-hive/hive.db'}`);
275
+ console.log(` ${agents.length} local agents · ${remotes.length} remote · ${teams.length} teams · ${tasks.cnt} active tasks · ${peers.length} peers`);
276
+ if (agents.length > 0) {
277
+ console.log(`\n👥 Agents`);
278
+ console.log(renderTable(AGENT_HEADERS, agentRows(db, agents), ' '));
113
279
  }
114
280
  if (teams.length > 0) {
115
- console.log(`\n🏠 Teams:`);
116
- for (const t of teams) {
117
- const memberCount = db.prepare('SELECT COUNT(*) as cnt FROM team_members WHERE team_id = ?').get(t.id);
118
- console.log(` ${t.name} — ${memberCount.cnt} members`);
281
+ console.log(`\n🏠 Teams`);
282
+ const rows = teams.map(t => {
283
+ const cnt = db.prepare('SELECT COUNT(*) as cnt FROM team_members WHERE team_id = ?').get(t.id).cnt;
284
+ return [t.name, String(cnt)];
285
+ });
286
+ console.log(renderTable(['NAME', 'MEMBERS'], rows, ' '));
287
+ }
288
+ if (peers.length > 0) {
289
+ console.log(`\n🤝 Peers`);
290
+ const rows = peers.map(p => [
291
+ p.name, p.node_name || '-', p.status, p.exposed || '-', relativeTime(p.last_seen),
292
+ ]);
293
+ console.log(renderTable(['NAME', 'NODE', 'STATUS', 'EXPOSED', 'LAST SEEN'], rows, ' '));
294
+ if (remotes.length > 0) {
295
+ console.log(`\n🌐 Remote agents (placeholders)`);
296
+ const rrows = remotes.map(a => [
297
+ a.id.slice(0, 12), a.display_name, a.origin_peer, a.status, relativeTime(a.last_seen),
298
+ ]);
299
+ console.log(renderTable(['ID', 'NAME', 'PEER', 'STATUS', 'LAST SEEN'], rrows, ' '));
119
300
  }
120
301
  }
121
302
  }
122
- catch (err) {
303
+ catch {
123
304
  console.log(`\n⚠️ Cannot read database`);
124
305
  }
125
306
  }
@@ -212,14 +393,12 @@ async function cmdAgentRename() {
212
393
  async function cmdAgentList() {
213
394
  const { dbPath } = parseFlags(1);
214
395
  const db = initDB(dbPath);
215
- const agents = db.prepare('SELECT display_name, roles, status, last_seen FROM agents ORDER BY last_seen DESC').all();
396
+ const agents = db.prepare('SELECT id, display_name, tool, roles, status, last_seen FROM agents ORDER BY last_seen DESC').all();
216
397
  if (agents.length === 0) {
217
398
  console.log('No agents registered.');
218
399
  return;
219
400
  }
220
- for (const a of agents) {
221
- console.log(` ${a.display_name} (${a.status}) roles=${a.roles || 'none'} last_seen=${a.last_seen}`);
222
- }
401
+ console.log(renderTable(AGENT_HEADERS, agentRows(db, agents)));
223
402
  }
224
403
  // --- Node config ---
225
404
  function getConfigPath() {
@@ -243,7 +422,7 @@ function setNodeConfig(config) {
243
422
  writeFileSync(p, JSON.stringify({ ...existing, ...config }, null, 2) + '\n');
244
423
  }
245
424
  function getNodeName() {
246
- return getNodeConfig().name || require('os').hostname().split('.')[0];
425
+ return getNodeConfig().name || hostname().split('.')[0];
247
426
  }
248
427
  // --- Peer commands ---
249
428
  async function cmdPeerAdd() {
@@ -291,6 +470,21 @@ async function cmdPeerAdd() {
291
470
  console.log(` URL: ${peerUrl}`);
292
471
  console.log(` Secret: ${secret}`);
293
472
  console.log(` Exposed agents: ${exposed || 'none (use --expose to add)'}`);
473
+ // Verify reachability via /federation/ping
474
+ process.stdout.write(` Pinging…`);
475
+ const result = await pingPeer(peerName, peerUrl, secret, 5000);
476
+ if (result.ok) {
477
+ if (result.node)
478
+ setPeerNodeName(peerName, result.node);
479
+ setPeerStatus(peerName, 'active');
480
+ touchPeer(peerName);
481
+ console.log(` ok (node="${result.node}")`);
482
+ }
483
+ else {
484
+ setPeerStatus(peerName, 'inactive');
485
+ console.log(` failed: ${result.error}`);
486
+ console.log(` (peer record kept; will retry on next heartbeat once server is reachable)`);
487
+ }
294
488
  console.log(`\n Give this secret to the peer so they can connect back.`);
295
489
  }
296
490
  async function cmdPeerList() {
@@ -301,11 +495,15 @@ async function cmdPeerList() {
301
495
  console.log('No peers configured.');
302
496
  return;
303
497
  }
304
- for (const p of peers) {
305
- console.log(` ${p.name} (${p.status}) — ${p.url}`);
306
- console.log(` exposed: ${p.exposed || 'none'}`);
307
- console.log(` last seen: ${p.last_seen || 'never'}`);
308
- }
498
+ const rows = peers.map(p => [
499
+ p.name,
500
+ p.node_name || '-',
501
+ p.status,
502
+ p.url,
503
+ p.exposed || '-',
504
+ relativeTime(p.last_seen),
505
+ ]);
506
+ console.log(renderTable(['NAME', 'NODE', 'STATUS', 'URL', 'EXPOSED', 'LAST SEEN'], rows));
309
507
  }
310
508
  async function cmdPeerRemove() {
311
509
  const { dbPath } = parseFlags(1);
@@ -322,6 +520,159 @@ async function cmdPeerRemove() {
322
520
  console.log(`Peer "${name}" not found.`);
323
521
  }
324
522
  }
523
+ function encodeInvite(p) {
524
+ return 'hive://' + Buffer.from(JSON.stringify(p)).toString('base64url');
525
+ }
526
+ function decodeInvite(token) {
527
+ let raw = token.trim();
528
+ if (raw.startsWith('hive://'))
529
+ raw = raw.slice('hive://'.length);
530
+ const json = Buffer.from(raw, 'base64url').toString();
531
+ const p = JSON.parse(json);
532
+ if (p.v !== 1)
533
+ throw new Error('Unsupported invite version');
534
+ if (!p.n || !p.u || !p.s || !p.e || !p.t)
535
+ throw new Error('Invite missing required fields');
536
+ return p;
537
+ }
538
+ async function cmdPeerInvite() {
539
+ const { dbPath } = parseFlags(1);
540
+ let exposed = '';
541
+ let url = '';
542
+ let port = 4123;
543
+ for (let i = 2; i < args.length; i++) {
544
+ if ((args[i] === '--as' || args[i] === '--expose') && args[i + 1]) {
545
+ exposed = args[i + 1];
546
+ i++;
547
+ }
548
+ else if (args[i] === '--url' && args[i + 1]) {
549
+ url = args[i + 1];
550
+ i++;
551
+ }
552
+ else if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
553
+ port = parseInt(args[i + 1], 10) || 4123;
554
+ i++;
555
+ }
556
+ }
557
+ if (!exposed) {
558
+ console.log('Usage: kitty-hive peer invite --as <agent-id> [--url https://your-public-url/mcp]');
559
+ console.log(' --as the agent on YOUR side that the peer should be allowed to reach');
560
+ console.log(' --url your hive URL as the peer will see it (default http://localhost:<port>/mcp)');
561
+ process.exit(1);
562
+ }
563
+ initDB(dbPath);
564
+ cleanupExpiredInvites();
565
+ let publicUrl = url || `http://localhost:${port}/mcp`;
566
+ if (!/\/mcp\/?$/.test(publicUrl))
567
+ publicUrl = publicUrl.replace(/\/+$/, '') + '/mcp';
568
+ const secret = 'sk_' + generateToken().slice(0, 32);
569
+ const invite = createPendingInvite(secret, exposed, publicUrl);
570
+ const nodeName = getNodeConfig().name || hostname().split('.')[0];
571
+ const token = encodeInvite({ v: 1, n: nodeName, u: publicUrl, s: secret, e: exposed, t: invite.token_id });
572
+ console.log(`🤝 Invite created (expires in 24h)`);
573
+ console.log(` Your URL: ${publicUrl}`);
574
+ console.log(` Exposed agent: ${exposed}`);
575
+ console.log(` Secret: ${secret}\n`);
576
+ console.log(` Send this token to your peer:\n`);
577
+ console.log(` ${token}\n`);
578
+ console.log(` On the other side, run:`);
579
+ console.log(` kitty-hive peer accept '${token}' --as <their-agent-id>`);
580
+ }
581
+ async function cmdPeerAccept() {
582
+ const { dbPath } = parseFlags(1);
583
+ let token = '';
584
+ let myExposed = '';
585
+ let myUrl = '';
586
+ let port = 4123;
587
+ for (let i = 2; i < args.length; i++) {
588
+ if ((args[i] === '--as' || args[i] === '--expose') && args[i + 1]) {
589
+ myExposed = args[i + 1];
590
+ i++;
591
+ }
592
+ else if (args[i] === '--url' && args[i + 1]) {
593
+ myUrl = args[i + 1];
594
+ i++;
595
+ }
596
+ else if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
597
+ port = parseInt(args[i + 1], 10) || 4123;
598
+ i++;
599
+ }
600
+ else if (!args[i].startsWith('-') && !token)
601
+ token = args[i];
602
+ }
603
+ if (!token || !myExposed) {
604
+ console.log('Usage: kitty-hive peer accept <token> --as <your-agent-id> [--url https://your-public-url/mcp]');
605
+ process.exit(1);
606
+ }
607
+ let invite;
608
+ try {
609
+ invite = decodeInvite(token);
610
+ }
611
+ catch (err) {
612
+ console.log(`Invalid invite: ${err.message}`);
613
+ process.exit(1);
614
+ }
615
+ initDB(dbPath);
616
+ console.log(`✓ Decoded invite from "${invite.n}"`);
617
+ console.log(` Their URL: ${invite.u}`);
618
+ console.log(` Their exposed agent: ${invite.e}\n`);
619
+ // Add their hive as a local peer.
620
+ // exposed = OUR local agent that THEY can reach (= myExposed), not their agent.
621
+ let peerName = invite.n;
622
+ if (getPeerByName(peerName)) {
623
+ peerName = `${invite.n}-${Date.now().toString(36).slice(-4)}`;
624
+ console.log(` (peer name "${invite.n}" taken; using "${peerName}")`);
625
+ }
626
+ addPeer(peerName, invite.u, invite.s, myExposed);
627
+ setPeerNodeName(peerName, invite.n);
628
+ console.log(`✓ Added ${peerName} as local peer`);
629
+ // Decide our URL
630
+ let ourUrl = myUrl || `http://localhost:${port}/mcp`;
631
+ if (!/\/mcp\/?$/.test(ourUrl))
632
+ ourUrl = ourUrl.replace(/\/+$/, '') + '/mcp';
633
+ const ourNode = getNodeConfig().name || hostname().split('.')[0];
634
+ // Handshake back: tell their hive how to reach us
635
+ process.stdout.write(`✓ Calling handshake on ${invite.u}…`);
636
+ const handshakeUrl = invite.u.replace(/\/mcp\/?$/, '/federation/handshake');
637
+ try {
638
+ const res = await fetch(handshakeUrl, {
639
+ method: 'POST',
640
+ headers: { 'Content-Type': 'application/json' },
641
+ body: JSON.stringify({
642
+ token_id: invite.t, secret: invite.s,
643
+ name: ourNode, url: ourUrl, exposed: myExposed,
644
+ }),
645
+ });
646
+ if (!res.ok) {
647
+ const err = await res.json().catch(() => ({ error: res.statusText }));
648
+ console.log(` failed: ${err.error || res.statusText}`);
649
+ console.log(` Local peer record was added; their side may not auto-add you.`);
650
+ console.log(` They can add you manually with:`);
651
+ console.log(` kitty-hive peer add ${ourNode} ${ourUrl} --secret ${invite.s} --expose ${myExposed}`);
652
+ process.exit(1);
653
+ }
654
+ const result = await res.json();
655
+ console.log(` ok (they added you as "${result.peer_name}")`);
656
+ }
657
+ catch (err) {
658
+ console.log(` failed: ${err.message}`);
659
+ console.log(` Local peer record was added; their side may not auto-add you.`);
660
+ process.exit(1);
661
+ }
662
+ // Verify reachability via ping
663
+ process.stdout.write(`✓ Pinging ${peerName}…`);
664
+ const ping = await pingPeer(peerName, invite.u, invite.s, 5000);
665
+ if (ping.ok) {
666
+ setPeerStatus(peerName, 'active');
667
+ touchPeer(peerName);
668
+ console.log(` ok (node="${ping.node}")`);
669
+ }
670
+ else {
671
+ console.log(` failed: ${ping.error} (will retry on next heartbeat)`);
672
+ }
673
+ console.log(`\n🎉 Peer "${peerName}" connected. Try:`);
674
+ console.log(` hive-remote-agents({ peer: "${peerName}" })`);
675
+ }
325
676
  async function cmdPeerExpose() {
326
677
  const { dbPath } = parseFlags(1);
327
678
  const peerName = args[2];
@@ -357,6 +708,18 @@ async function cmdPeerExpose() {
357
708
  updatePeerExposed(peerName, current.join(','));
358
709
  console.log(`✅ Peer "${peerName}" exposed agents: ${current.join(', ') || 'none'}`);
359
710
  }
711
+ async function cmdFilesClean() {
712
+ let maxAgeDays = 7;
713
+ for (let i = 2; i < args.length; i++) {
714
+ if ((args[i] === '--days' || args[i] === '-d') && args[i + 1]) {
715
+ maxAgeDays = parseInt(args[i + 1], 10) || 7;
716
+ i++;
717
+ }
718
+ }
719
+ const { cleanupOldFiles } = await import('./federation-http.js');
720
+ const result = cleanupOldFiles(maxAgeDays);
721
+ console.log(`✅ Removed ${result.removed} federation file(s) older than ${maxAgeDays} day(s); kept ${result.kept}.`);
722
+ }
360
723
  async function cmdConfigSet() {
361
724
  // kitty-hive config set name marvin
362
725
  const key = args[2];
@@ -381,24 +744,27 @@ function getDefaultAgentName() {
381
744
  catch { /* ignore */ }
382
745
  }
383
746
  // Fallback to directory name
384
- return process.cwd().split('/').pop() || 'agent';
747
+ return basename(process.cwd()) || 'agent';
385
748
  }
386
749
  function showHelp() {
387
750
  console.log(`🐝 kitty-hive — multi-agent collaboration server
388
751
 
389
752
  Usage:
390
753
  kitty-hive serve [--port 4123] [--db path] [-v|-q] Start the server
391
- kitty-hive init [--port 4123] Configure HTTP MCP for this project
754
+ kitty-hive init <tool> [--port 4123] Write MCP config (claude|cursor|vscode|antigravity|all)
392
755
  kitty-hive status [--port 4123] Server & agent status
393
756
  kitty-hive agent list List agents
394
757
  kitty-hive agent rename <old> <new> Rename an agent
395
758
  kitty-hive agent remove <name> Remove an agent
396
- kitty-hive peer add <name> <url> [--expose a,b] [--secret s] Add a peer
759
+ kitty-hive peer invite --as <agent> [--url url] Create invite token (recommended)
760
+ kitty-hive peer accept <token> --as <agent> [--url url] Accept an invite token
761
+ kitty-hive peer add <name> <url> [--expose a,b] [--secret s] Add a peer (manual)
397
762
  kitty-hive peer list List peers
398
763
  kitty-hive peer remove <name> Remove a peer
399
764
  kitty-hive peer expose <name> --add/--remove <agent> Manage exposed agents
400
765
  kitty-hive config set <key> <value> Set config (e.g. name)
401
- kitty-hive db clear [--db path] Clear the database`);
766
+ kitty-hive db clear [--db path] Clear the database
767
+ kitty-hive files clean [--days 7] Remove old federation transfer files`);
402
768
  }
403
769
  // --- Main ---
404
770
  switch (command) {
@@ -438,6 +804,12 @@ switch (command) {
438
804
  else if (args[1] === 'expose') {
439
805
  cmdPeerExpose().catch(err => { console.error('Failed:', err); process.exit(1); });
440
806
  }
807
+ else if (args[1] === 'invite') {
808
+ cmdPeerInvite().catch(err => { console.error('Failed:', err); process.exit(1); });
809
+ }
810
+ else if (args[1] === 'accept') {
811
+ cmdPeerAccept().catch(err => { console.error('Failed:', err); process.exit(1); });
812
+ }
441
813
  else {
442
814
  showHelp();
443
815
  }
@@ -458,6 +830,14 @@ switch (command) {
458
830
  showHelp();
459
831
  }
460
832
  break;
833
+ case 'files':
834
+ if (args[1] === 'clean') {
835
+ cmdFilesClean().catch(err => { console.error('Failed:', err); process.exit(1); });
836
+ }
837
+ else {
838
+ showHelp();
839
+ }
840
+ break;
461
841
  default:
462
842
  showHelp();
463
843
  break;