kitty-hive 0.6.2 → 0.6.3

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
@@ -334,11 +334,15 @@ async function cmdDbClear() {
334
334
  }
335
335
  async function cmdAgentRemove() {
336
336
  const { dbPath } = parseFlags(1);
337
- // Flags: --key <K> (look up by external_key, idempotent: missing = exit 0),
338
- // --yes (skip confirmation; required for non-interactive scripts)
337
+ // Flags: --key <K> look up by external_key (idempotent: missing = exit 0)
338
+ // --yes skip confirmation (scripts)
339
+ // --transfer-to <a> transfer hosted teams to this agent before deleting
340
+ // --cascade explicitly accept that hosted teams (and all their members' history) will be wiped
339
341
  let target = '';
340
342
  let externalKey = '';
341
343
  let assumeYes = false;
344
+ let transferTo = '';
345
+ let cascade = false;
342
346
  for (let i = 2; i < args.length; i++) {
343
347
  if (args[i] === '--key' && args[i + 1]) {
344
348
  externalKey = args[i + 1];
@@ -347,6 +351,13 @@ async function cmdAgentRemove() {
347
351
  else if (args[i] === '--yes' || args[i] === '-y') {
348
352
  assumeYes = true;
349
353
  }
354
+ else if (args[i] === '--transfer-to' && args[i + 1]) {
355
+ transferTo = args[i + 1];
356
+ i++;
357
+ }
358
+ else if (args[i] === '--cascade') {
359
+ cascade = true;
360
+ }
350
361
  else if (args[i] === '--port' || args[i] === '-p' || args[i] === '--db') {
351
362
  i++;
352
363
  }
@@ -365,22 +376,31 @@ async function cmdAgentRemove() {
365
376
  agent = row;
366
377
  }
367
378
  else if (target) {
368
- const matches = db.prepare('SELECT id, display_name FROM agents WHERE id = ? OR display_name = ?').all(target, target);
369
- if (matches.length === 0) {
370
- console.log(`Agent "${target}" not found.`);
371
- process.exit(1);
379
+ // ID is unique try ID first; only fall back to display_name match if no ID hit.
380
+ // This avoids a collision where someone registered an agent whose display_name
381
+ // is literally another agent's ID.
382
+ const byId = db.prepare('SELECT id, display_name FROM agents WHERE id = ?').get(target);
383
+ if (byId) {
384
+ agent = byId;
372
385
  }
373
- if (matches.length > 1) {
374
- console.log(`"${target}" matches ${matches.length} agents. Use id to disambiguate.`);
375
- for (const m of matches)
376
- console.log(` ${m.id} ${m.display_name}`);
377
- process.exit(1);
386
+ else {
387
+ const matches = db.prepare('SELECT id, display_name FROM agents WHERE display_name = ?').all(target);
388
+ if (matches.length === 0) {
389
+ console.log(`Agent "${target}" not found.`);
390
+ process.exit(1);
391
+ }
392
+ if (matches.length > 1) {
393
+ console.log(`"${target}" matches ${matches.length} agents. Use id to disambiguate.`);
394
+ for (const m of matches)
395
+ console.log(` ${m.id} ${m.display_name}`);
396
+ process.exit(1);
397
+ }
398
+ agent = matches[0];
378
399
  }
379
- agent = matches[0];
380
400
  }
381
401
  else {
382
402
  if (!isInteractive()) {
383
- console.log('Usage: kitty-hive agent remove <name-or-id> | --key <key> [--yes]');
403
+ console.log('Usage: kitty-hive agent remove <name-or-id> | --key <key> [--yes] [--transfer-to <agent>] [--cascade]');
384
404
  process.exit(1);
385
405
  }
386
406
  const id = await pickLocalAgent(db, 'Remove which agent?');
@@ -391,16 +411,121 @@ async function cmdAgentRemove() {
391
411
  process.exit(1);
392
412
  }
393
413
  const name = agent.display_name;
414
+ // --- Hosted-team pre-check ---
415
+ const dbMod = await import('./db.js');
416
+ const hostedTeams = dbMod.listTeamsHostedBy(agent.id);
417
+ const hostedRows = hostedTeams.map(t => {
418
+ const members = dbMod.getTeamMembers(t.id);
419
+ return {
420
+ team: t,
421
+ memberCount: members.length,
422
+ otherMemberCount: members.filter(m => m.agent_id !== agent.id).length,
423
+ };
424
+ });
425
+ if (hostedRows.length > 0) {
426
+ console.log(`\n⚠ Agent "${name}" hosts ${hostedRows.length} team(s):`);
427
+ for (const r of hostedRows) {
428
+ console.log(` • ${r.team.name} (${r.team.id}) — ${r.memberCount} member(s), ${r.otherMemberCount} other`);
429
+ }
430
+ console.log('');
431
+ }
432
+ // Decision is only required when at least one hosted team has *other* members.
433
+ // A team where the leaving agent is the sole member is safe to cascade-delete silently
434
+ // (no other history is destroyed), but we still confirm overall removal below.
435
+ let transferAgent;
436
+ const needsHostDecision = hostedRows.some(r => r.otherMemberCount > 0);
437
+ if (needsHostDecision) {
438
+ if (transferTo) {
439
+ const t = db.prepare('SELECT id, display_name FROM agents WHERE id = ? OR display_name = ?').get(transferTo, transferTo);
440
+ if (!t) {
441
+ console.error(`--transfer-to: agent "${transferTo}" not found.`);
442
+ process.exit(1);
443
+ }
444
+ transferAgent = t;
445
+ }
446
+ else if (!cascade && !assumeYes && isInteractive()) {
447
+ // Repeat the team listing inside the prompt itself — the warning lines
448
+ // printed earlier may be scrolled off-screen by clack's render area.
449
+ const teamLines = hostedRows.map(r => {
450
+ const marker = r.otherMemberCount > 0 ? '⚠' : ' ';
451
+ return ` ${marker} ${r.team.name} (${r.memberCount} member${r.memberCount === 1 ? '' : 's'}${r.otherMemberCount > 0 ? `, ${r.otherMemberCount} other` : ''})`;
452
+ }).join('\n');
453
+ const promptMsg = `Agent "${name}" hosts ${hostedRows.length} team(s):\n${teamLines}\n\nHow should they be handled?`;
454
+ const choice = await askSelect({
455
+ message: promptMsg,
456
+ options: [
457
+ { value: 'transfer', label: 'Transfer host to another local agent', hint: 'keeps team history & members' },
458
+ { value: 'cascade', label: 'Delete teams (and all their members + history)', hint: 'destructive, irreversible' },
459
+ { value: 'abort', label: 'Cancel — do nothing' },
460
+ ],
461
+ initialValue: 'transfer',
462
+ });
463
+ if (choice === 'abort') {
464
+ console.log('Cancelled.');
465
+ process.exit(0);
466
+ }
467
+ if (choice === 'transfer') {
468
+ const id = await pickLocalAgent(db, 'Transfer host to which agent?');
469
+ if (id === agent.id) {
470
+ console.error('Cannot transfer to self.');
471
+ process.exit(1);
472
+ }
473
+ transferAgent = db.prepare('SELECT id, display_name FROM agents WHERE id = ?').get(id);
474
+ }
475
+ else {
476
+ cascade = true;
477
+ }
478
+ }
479
+ else if (!cascade) {
480
+ console.error('Hosted teams detected. Pass --transfer-to <agent> to keep them, or --cascade to delete them with their members + history.');
481
+ process.exit(1);
482
+ }
483
+ }
484
+ if (transferAgent && transferAgent.id === agent.id) {
485
+ console.error('Cannot transfer hosted teams to the agent being removed.');
486
+ process.exit(1);
487
+ }
394
488
  if (!assumeYes) {
395
- const ok = await askConfirm({
396
- message: `Remove agent "${name}" (${agent.id}) and all related data?`,
397
- initialValue: false,
398
- });
489
+ const summary = transferAgent
490
+ ? `Remove agent "${name}" (${agent.id}); transfer ${hostedRows.length} hosted team(s) to "${transferAgent.display_name}".`
491
+ : hostedRows.length > 0
492
+ ? `Remove agent "${name}" AND cascade-delete ${hostedRows.length} hosted team(s) with all members + history?`
493
+ : `Remove agent "${name}" (${agent.id}) and all related data?`;
494
+ const ok = await askConfirm({ message: summary, initialValue: false });
399
495
  if (!ok) {
400
496
  console.log('Cancelled.');
401
497
  process.exit(0);
402
498
  }
403
499
  }
500
+ // --- Apply transfer first (so we don't cascade-delete those teams below) ---
501
+ if (transferAgent) {
502
+ const sessionsMod = await import('./sessions.js');
503
+ for (const r of hostedRows) {
504
+ // Make sure new host is a member; if not, add them.
505
+ if (!dbMod.isTeamMember(r.team.id, transferAgent.id)) {
506
+ dbMod.addTeamMember(r.team.id, transferAgent.id);
507
+ }
508
+ dbMod.transferTeamHost(r.team.id, transferAgent.id);
509
+ dbMod.appendTeamEvent(r.team.id, 'host-transfer', null, {
510
+ from_agent_id: agent.id,
511
+ from_display_name: name,
512
+ to_agent_id: transferAgent.id,
513
+ to_display_name: transferAgent.display_name,
514
+ reason: 'agent-remove',
515
+ });
516
+ try {
517
+ await sessionsMod.notifyTeamMembers(r.team.id, undefined, JSON.stringify({
518
+ type: 'team-host-transfer',
519
+ team_id: r.team.id,
520
+ team_name: r.team.name,
521
+ from: name,
522
+ to: transferAgent.display_name,
523
+ }));
524
+ }
525
+ catch { /* push best-effort */ }
526
+ }
527
+ console.log(`↪ Transferred ${hostedRows.length} hosted team(s) to "${transferAgent.display_name}".`);
528
+ }
404
529
  // Delete in dependency order to satisfy foreign keys
405
530
  db.prepare('DELETE FROM read_cursors WHERE agent_id = ?').run(agent.id);
406
531
  // Tasks
@@ -413,7 +538,7 @@ async function cmdAgentRemove() {
413
538
  // Team membership + events
414
539
  db.prepare('DELETE FROM team_members WHERE agent_id = ?').run(agent.id);
415
540
  db.prepare('DELETE FROM team_events WHERE actor_agent_id = ?').run(agent.id);
416
- // Teams hosted by this agent (and their content)
541
+ // Teams hosted by this agent (only those left — transferred ones now have a different host)
417
542
  db.prepare(`DELETE FROM team_events WHERE team_id IN (SELECT id FROM teams WHERE host_agent_id = ?)`).run(agent.id);
418
543
  db.prepare(`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE host_agent_id = ?)`).run(agent.id);
419
544
  db.prepare(`DELETE FROM read_cursors WHERE target_id IN (SELECT id FROM teams WHERE host_agent_id = ?)`).run(agent.id);
@@ -499,17 +624,25 @@ async function cmdAgentRename() {
499
624
  oldDisplay = db.prepare('SELECT display_name FROM agents WHERE id = ?').get(agentId).display_name;
500
625
  }
501
626
  else {
502
- const matches = db.prepare('SELECT id, display_name FROM agents WHERE display_name = ? OR id = ?').all(oldName, oldName);
503
- if (matches.length === 0) {
504
- console.log(`Agent "${oldName}" not found.`);
505
- process.exit(1);
627
+ // ID-first to avoid collision with display_name that equals some agent's ID.
628
+ const byId = db.prepare('SELECT id, display_name FROM agents WHERE id = ?').get(oldName);
629
+ if (byId) {
630
+ agentId = byId.id;
631
+ oldDisplay = byId.display_name;
506
632
  }
507
- if (matches.length > 1) {
508
- console.log(`"${oldName}" matches ${matches.length} agents. Use agent id to disambiguate.`);
509
- process.exit(1);
633
+ else {
634
+ const matches = db.prepare('SELECT id, display_name FROM agents WHERE display_name = ?').all(oldName);
635
+ if (matches.length === 0) {
636
+ console.log(`Agent "${oldName}" not found.`);
637
+ process.exit(1);
638
+ }
639
+ if (matches.length > 1) {
640
+ console.log(`"${oldName}" matches ${matches.length} agents. Use agent id to disambiguate.`);
641
+ process.exit(1);
642
+ }
643
+ agentId = matches[0].id;
644
+ oldDisplay = matches[0].display_name;
510
645
  }
511
- agentId = matches[0].id;
512
- oldDisplay = matches[0].display_name;
513
646
  }
514
647
  if (!newName) {
515
648
  if (!isInteractive()) {
@@ -535,6 +668,166 @@ async function cmdAgentList() {
535
668
  }
536
669
  console.log(renderTable(AGENT_HEADERS, agentRows(db, agents)));
537
670
  }
671
+ // --- Team commands (CLI-only; agents use hive-team-* MCP tools instead) ---
672
+ async function cmdTeamList() {
673
+ const { dbPath } = parseFlags(1);
674
+ initDB(dbPath);
675
+ const dbMod = await import('./db.js');
676
+ const teams = dbMod.listTeams(false);
677
+ if (teams.length === 0) {
678
+ console.log('No teams.');
679
+ return;
680
+ }
681
+ for (const t of teams) {
682
+ const host = t.host_agent_id ? (getAgentById(t.host_agent_id)?.display_name ?? t.host_agent_id.slice(-12)) : '(none)';
683
+ const memberCount = dbMod.countTeamMembers(t.id);
684
+ const status = t.closed_at ? 'closed' : 'active';
685
+ console.log(`${t.id} ${t.name} host=${host} members=${memberCount} ${status}`);
686
+ }
687
+ }
688
+ async function cmdTeamTransfer() {
689
+ const { dbPath } = parseFlags(1);
690
+ let teamArg = '';
691
+ let toArg = '';
692
+ let assumeYes = false;
693
+ let addMember = false;
694
+ for (let i = 2; i < args.length; i++) {
695
+ if (args[i] === '--to' && args[i + 1]) {
696
+ toArg = args[i + 1];
697
+ i++;
698
+ }
699
+ else if (args[i] === '--yes' || args[i] === '-y') {
700
+ assumeYes = true;
701
+ }
702
+ else if (args[i] === '--add-member') {
703
+ addMember = true;
704
+ }
705
+ else if (args[i] === '--port' || args[i] === '-p' || args[i] === '--db') {
706
+ i++;
707
+ }
708
+ else if (!args[i].startsWith('-') && !teamArg)
709
+ teamArg = args[i];
710
+ }
711
+ const db = initDB(dbPath);
712
+ const dbMod = await import('./db.js');
713
+ // --- Resolve team ---
714
+ let team;
715
+ if (teamArg) {
716
+ team = dbMod.getTeamById(teamArg) || dbMod.getTeamByName(teamArg);
717
+ if (!team) {
718
+ console.error(`Team "${teamArg}" not found.`);
719
+ process.exit(1);
720
+ }
721
+ }
722
+ else {
723
+ if (!isInteractive()) {
724
+ console.error('Usage: kitty-hive team transfer <team> --to <agent> [--add-member] [--yes]');
725
+ process.exit(1);
726
+ }
727
+ const teams = dbMod.listTeams(false);
728
+ if (teams.length === 0) {
729
+ console.log('No teams to transfer.');
730
+ process.exit(0);
731
+ }
732
+ const teamId = await askSelect({
733
+ message: 'Transfer which team?',
734
+ options: teams.map(t => ({
735
+ value: t.id,
736
+ label: t.name,
737
+ hint: `host=${t.host_agent_id ? (getAgentById(t.host_agent_id)?.display_name ?? t.host_agent_id.slice(-12)) : '(none)'} · ${dbMod.countTeamMembers(t.id)} member(s)`,
738
+ })),
739
+ });
740
+ team = teams.find(t => t.id === teamId);
741
+ }
742
+ const currentHostName = team.host_agent_id ? (getAgentById(team.host_agent_id)?.display_name ?? team.host_agent_id.slice(-12)) : '(none)';
743
+ console.log(`\nTeam: ${team.name} (${team.id})\nCurrent host: ${currentHostName}\n`);
744
+ // --- Resolve new host ---
745
+ let newHost;
746
+ if (toArg) {
747
+ // ID-first: ID is unique, only fall back to display_name lookup on no ID hit.
748
+ const byId = db.prepare("SELECT id, display_name FROM agents WHERE id = ? AND origin_peer = ''").get(toArg);
749
+ if (byId) {
750
+ newHost = byId;
751
+ }
752
+ else {
753
+ const matches = db.prepare("SELECT id, display_name FROM agents WHERE display_name = ? AND origin_peer = ''").all(toArg);
754
+ if (matches.length === 0) {
755
+ console.error(`--to: local agent "${toArg}" not found.`);
756
+ process.exit(1);
757
+ }
758
+ if (matches.length > 1) {
759
+ console.error(`--to "${toArg}" matches ${matches.length} agents. Use id to disambiguate.`);
760
+ process.exit(1);
761
+ }
762
+ newHost = matches[0];
763
+ }
764
+ }
765
+ else {
766
+ if (!isInteractive()) {
767
+ console.error('Missing --to <agent>.');
768
+ process.exit(1);
769
+ }
770
+ const id = await pickLocalAgent(db, 'New host?');
771
+ newHost = db.prepare('SELECT id, display_name FROM agents WHERE id = ?').get(id);
772
+ }
773
+ if (!newHost) {
774
+ console.error('New host not found.');
775
+ process.exit(1);
776
+ }
777
+ if (newHost.id === team.host_agent_id) {
778
+ console.log(`"${newHost.display_name}" is already the host. Nothing to do.`);
779
+ process.exit(0);
780
+ }
781
+ // --- Membership check ---
782
+ if (!dbMod.isTeamMember(team.id, newHost.id)) {
783
+ if (!addMember && !assumeYes && isInteractive()) {
784
+ const ok = await askConfirm({
785
+ message: `"${newHost.display_name}" is not a member of "${team.name}". Add them as a member?`,
786
+ initialValue: true,
787
+ });
788
+ if (!ok) {
789
+ console.log('Cancelled.');
790
+ process.exit(0);
791
+ }
792
+ addMember = true;
793
+ }
794
+ else if (!addMember) {
795
+ console.error(`"${newHost.display_name}" is not a member of "${team.name}". Re-run with --add-member to auto-join them, or have them join first.`);
796
+ process.exit(1);
797
+ }
798
+ dbMod.addTeamMember(team.id, newHost.id);
799
+ }
800
+ if (!assumeYes) {
801
+ const ok = await askConfirm({
802
+ message: `Transfer host of "${team.name}" from ${currentHostName} → ${newHost.display_name}?`,
803
+ initialValue: true,
804
+ });
805
+ if (!ok) {
806
+ console.log('Cancelled.');
807
+ process.exit(0);
808
+ }
809
+ }
810
+ dbMod.transferTeamHost(team.id, newHost.id);
811
+ dbMod.appendTeamEvent(team.id, 'host-transfer', null, {
812
+ from_agent_id: team.host_agent_id,
813
+ from_display_name: currentHostName,
814
+ to_agent_id: newHost.id,
815
+ to_display_name: newHost.display_name,
816
+ reason: 'team-transfer',
817
+ });
818
+ try {
819
+ const sessionsMod = await import('./sessions.js');
820
+ await sessionsMod.notifyTeamMembers(team.id, undefined, JSON.stringify({
821
+ type: 'team-host-transfer',
822
+ team_id: team.id,
823
+ team_name: team.name,
824
+ from: currentHostName,
825
+ to: newHost.display_name,
826
+ }));
827
+ }
828
+ catch { /* push best-effort */ }
829
+ console.log(`✅ Host of "${team.name}" → ${newHost.display_name}.`);
830
+ }
538
831
  // --- Node config ---
539
832
  function getConfigPath() {
540
833
  return join(homedir(), '.kitty-hive', 'config.json');
@@ -1436,7 +1729,8 @@ Top-level commands:
1436
1729
  status [--port 4123] Server & agent status
1437
1730
 
1438
1731
  Command groups:
1439
- agent Manage local agents (list, rename, remove)
1732
+ agent Manage local agents (list, rename, remove, register)
1733
+ team Inspect/maintain teams (list, transfer)
1440
1734
  peer Manage federation peers (invite, accept, add, list, expose, set-url, remove)
1441
1735
  log Inspect message history (dm, team, task)
1442
1736
  tunnel Cloudflare tunnel control (start, status)
@@ -1457,9 +1751,25 @@ Subcommands:
1457
1751
  list List all agents (local + remote placeholders)
1458
1752
  rename [old] [new] Rename an agent (interactive when args omitted)
1459
1753
  remove [name-or-id] | --key <K> [--yes] Remove an agent. --key is idempotent (missing = exit 0).
1754
+ Hosted teams: pass --transfer-to <agent> to keep them,
1755
+ or --cascade to delete them with all members + history.
1460
1756
  register --key <K> --display-name <N> [--roles R] Idempotent upsert by external_key.
1461
1757
  Stdout = agent_id (one line). Designed for orchestrator scripts.`);
1462
1758
  }
1759
+ function showTeamHelp() {
1760
+ console.log(`🐝 kitty-hive team — local team maintenance
1761
+
1762
+ Usage:
1763
+ kitty-hive team <subcommand> [args]
1764
+
1765
+ Subcommands:
1766
+ list List all teams with host + member counts
1767
+ transfer [<team>] --to <agent> [--add-member] Transfer team host to another local agent.
1768
+ --add-member auto-joins target if not yet a member.
1769
+
1770
+ Note: agents normally manage teams via the hive-team-* MCP tools.
1771
+ This group is for operator-level maintenance (e.g. before removing a host agent).`);
1772
+ }
1463
1773
  function showPeerHelp() {
1464
1774
  console.log(`🐝 kitty-hive peer — federation peer management
1465
1775
 
@@ -1564,6 +1874,19 @@ switch (command) {
1564
1874
  break;
1565
1875
  }
1566
1876
  break;
1877
+ case 'team':
1878
+ switch (args[1]) {
1879
+ case 'list':
1880
+ run(cmdTeamList);
1881
+ break;
1882
+ case 'transfer':
1883
+ run(cmdTeamTransfer);
1884
+ break;
1885
+ default:
1886
+ showTeamHelp();
1887
+ break;
1888
+ }
1889
+ break;
1567
1890
  case 'peer':
1568
1891
  switch (args[1]) {
1569
1892
  case 'add':