agent-comm-hub 0.3.0 → 0.5.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/lib/cli.js CHANGED
@@ -512,6 +512,12 @@ var AgentHub = class {
512
512
  const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
513
513
  return filtered.slice(-Math.max(0, limit)).reverse();
514
514
  }
515
+ /** Most recent messages across every peer, unfiltered (newest first).
516
+ * Backs `bridge_history { peer: "all" }` — lets an archiver (the desktop
517
+ * app) capture peer-to-peer traffic it is not a party of. */
518
+ historyAll(limit) {
519
+ return this.historyRing.slice(-Math.max(0, limit)).reverse();
520
+ }
515
521
  /**
516
522
  * Live summary for the status tool. `livePeers` (sessions with a live SSE
517
523
  * stream) count as connected even without recent tool activity.
@@ -840,9 +846,13 @@ function hubTools(hub, registry, options) {
840
846
  },
841
847
  {
842
848
  name: "bridge_history",
843
- description: "Recent messages involving you (newest first); pass `peer` to inspect another peer's conversation. Use to refresh context after a reconnect.",
844
- inputSchema: schema({ peer: optStr("PeerId whose conversation to inspect; default: yourself."), limit: int("How many messages to return (default 20).") }),
845
- handler: wrap(true, async (args, peer) => ({ messages: hub.history(args.peer === void 0 ? peer : String(args.peer), Math.min(args.limit === void 0 ? 20 : Number(args.limit), 100)).map(present) }))
849
+ description: 'Recent messages involving you (newest first); pass `peer` to inspect another peer\'s conversation, or `peer: "all"` for the unfiltered tail across every peer. Use to refresh context after a reconnect.',
850
+ inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself.'), limit: int("How many messages to return (default 20).") }),
851
+ handler: wrap(true, async (args, peer) => {
852
+ const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
853
+ const messages = args.peer === "all" ? hub.historyAll(limit) : hub.history(args.peer === void 0 ? peer : String(args.peer), limit);
854
+ return { messages: messages.map(present) };
855
+ })
846
856
  },
847
857
  // ---- herdr control tools ------------------------------------------
848
858
  // These type into real agent terminals via the herdr runtime. They are
@@ -1334,7 +1344,7 @@ function readBody(req) {
1334
1344
 
1335
1345
  // src/index.ts
1336
1346
  var SERVER_NAME = "agent-comm-hub";
1337
- var SERVER_VERSION = "0.3.0";
1347
+ var SERVER_VERSION = "0.5.0";
1338
1348
  var DEFAULT_HOST = "127.0.0.1";
1339
1349
  var DEFAULT_PORT = 18764;
1340
1350
  var DEFAULT_PATH = "/mcp";
@@ -1343,7 +1353,10 @@ var DEFAULT_CONFIG = {
1343
1353
  port: DEFAULT_PORT,
1344
1354
  path: DEFAULT_PATH,
1345
1355
  maxQueue: 200,
1346
- historyLimit: 100,
1356
+ // 100 was too small to survive a long multi-agent session: the ring is the
1357
+ // only archive source until the desktop app persists it to SQLite, and a
1358
+ // night of agent-to-agent chatter evicts everything within minutes.
1359
+ historyLimit: 1e3,
1347
1360
  waitTimeoutMs: 6e4,
1348
1361
  defaultWaitMs: 3e4,
1349
1362
  connectedWindowMs: 3e4,
@@ -1410,15 +1423,190 @@ function startHub(config = {}, log2 = console) {
1410
1423
  }
1411
1424
 
1412
1425
  // src/setup.ts
1413
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
1414
- import { existsSync } from "node:fs";
1426
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1427
+ import { existsSync as existsSync2 } from "node:fs";
1428
+ import { homedir as homedir2 } from "node:os";
1429
+ import { dirname as dirname2, join as join2 } from "node:path";
1430
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1431
+
1432
+ // src/discover.ts
1433
+ import { execFileSync } from "node:child_process";
1434
+ import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
1415
1435
  import { homedir } from "node:os";
1416
- import { dirname, join } from "node:path";
1436
+ import { dirname, join, sep } from "node:path";
1417
1437
  import { fileURLToPath } from "node:url";
1438
+ function registryFile() {
1439
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1440
+ }
1441
+ function expandHome(file, home) {
1442
+ return file.startsWith("~/") ? join(home, file.slice(2)) : file;
1443
+ }
1444
+ function expandConfigFile(file, home) {
1445
+ const expanded = expandHome(file, home);
1446
+ const star = expanded.indexOf("*");
1447
+ if (star < 0) return [expanded];
1448
+ const prefix = expanded.slice(0, star);
1449
+ const suffix = expanded.slice(star + 1);
1450
+ const base = prefix.slice(0, prefix.lastIndexOf(sep));
1451
+ if (!existsSync(base)) return [];
1452
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
1453
+ }
1454
+ function validateRegistry(registry) {
1455
+ if (!Array.isArray(registry.agents)) throw new Error('registry: missing "agents" array');
1456
+ const ids = /* @__PURE__ */ new Set();
1457
+ for (const agent of registry.agents) {
1458
+ if (typeof agent.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
1459
+ throw new Error(`registry: bad agent id ${JSON.stringify(agent.id)}`);
1460
+ }
1461
+ if (ids.has(agent.id)) throw new Error(`registry: duplicate agent id '${agent.id}'`);
1462
+ ids.add(agent.id);
1463
+ if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
1464
+ if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
1465
+ if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
1466
+ for (const config of agent.configs) {
1467
+ if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
1468
+ throw new Error(`registry: ${agent.id}: config file must be '~'-relative`);
1469
+ }
1470
+ if (config.file.split("/").includes("..")) {
1471
+ throw new Error(`registry: ${agent.id}: config file must not contain '..'`);
1472
+ }
1473
+ if (config.file.split("*").length > 2) {
1474
+ throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
1475
+ }
1476
+ if (!["json", "toml", "dsh"].includes(config.strategy)) {
1477
+ throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
1478
+ }
1479
+ if (config.strategy === "json" && (typeof config.section !== "string" || config.entry === null)) {
1480
+ throw new Error(`registry: ${agent.id}: json strategy needs a section and an entry`);
1481
+ }
1482
+ if (config.strategy !== "json" && config.entry !== null) {
1483
+ throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
1484
+ }
1485
+ }
1486
+ if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
1487
+ throw new Error(`registry: ${agent.id}: skill must be '~'-relative or null`);
1488
+ }
1489
+ if (agent.os !== void 0 && (!Array.isArray(agent.os) || agent.os.some((os2) => !["win32", "darwin", "linux"].includes(os2)))) {
1490
+ throw new Error(`registry: ${agent.id}: invalid os list`);
1491
+ }
1492
+ }
1493
+ }
1494
+ function loadRegistry(file = registryFile()) {
1495
+ const registry = JSON.parse(readFileSync(file, "utf8"));
1496
+ validateRegistry(registry);
1497
+ return registry;
1498
+ }
1499
+ function commandOnPath(command, pathEnv, pathext, platform) {
1500
+ const dirs = pathEnv.split(platform === "win32" ? ";" : ":");
1501
+ const extensions = platform === "win32" ? ["", ...(pathext || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
1502
+ for (const dir of dirs) {
1503
+ const base = dir === "" ? "." : dir;
1504
+ for (const ext of extensions) {
1505
+ const candidate = join(base, command + ext);
1506
+ try {
1507
+ accessSync(candidate, fsConstants.X_OK);
1508
+ return true;
1509
+ } catch {
1510
+ }
1511
+ }
1512
+ }
1513
+ return false;
1514
+ }
1515
+ function readNpmNames(root) {
1516
+ const names = [];
1517
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
1518
+ if (!entry.isDirectory()) continue;
1519
+ if (entry.name.startsWith("@")) {
1520
+ const scopeDir = join(root, entry.name);
1521
+ for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
1522
+ if (sub.isDirectory()) names.push(sub.name);
1523
+ }
1524
+ } else {
1525
+ names.push(entry.name);
1526
+ }
1527
+ }
1528
+ return names;
1529
+ }
1530
+ function npmGlobalRoot(platform) {
1531
+ try {
1532
+ const out = execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { encoding: "utf8", windowsHide: true }).trim();
1533
+ return out || null;
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ }
1538
+ function npmFallbackRoots(home, platform) {
1539
+ if (platform === "win32") {
1540
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
1541
+ return [join(appData, "npm", "node_modules")];
1542
+ }
1543
+ return [
1544
+ "/usr/local/lib/node_modules",
1545
+ "/usr/lib/node_modules",
1546
+ ...existsSync(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
1547
+ ];
1548
+ }
1549
+ function discover(registry, options = {}) {
1550
+ const home = options.homeDir ?? homedir();
1551
+ const platform = options.platform ?? process.platform;
1552
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
1553
+ const pathext = options.pathext ?? process.env.PATHEXT ?? "";
1554
+ let npmNames = null;
1555
+ if (!options.noNpm) {
1556
+ if (options.npmRoot !== void 0 && options.npmRoot !== null) {
1557
+ npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
1558
+ } else if (options.npmRoot !== null) {
1559
+ const root = npmGlobalRoot(platform);
1560
+ if (root && existsSync(root)) {
1561
+ npmNames = readNpmNames(root);
1562
+ } else {
1563
+ npmNames = [];
1564
+ for (const fallback of npmFallbackRoots(home, platform)) {
1565
+ if (existsSync(fallback)) {
1566
+ npmNames = readNpmNames(fallback);
1567
+ break;
1568
+ }
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+ return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
1574
+ let source = "none";
1575
+ const configFiles = [];
1576
+ for (const config of agent.configs) {
1577
+ for (const file of expandConfigFile(config.file, home)) {
1578
+ if (existsSync(file)) {
1579
+ configFiles.push(file);
1580
+ if (source === "none") source = "config";
1581
+ }
1582
+ }
1583
+ }
1584
+ if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
1585
+ source = "path";
1586
+ }
1587
+ if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
1588
+ source = "npm";
1589
+ }
1590
+ return { id: agent.id, source, configFiles, present: source !== "none" };
1591
+ });
1592
+ }
1593
+ function runDiscover(options = {}) {
1594
+ const log2 = options.log ?? ((message) => console.log(message));
1595
+ const found = discover(loadRegistry(), options);
1596
+ log2("discovered agents:");
1597
+ for (const agent of found) {
1598
+ const status = agent.present ? agent.source : "not installed";
1599
+ const files = agent.configFiles.length > 0 ? ` \u2014 ${agent.configFiles.join(", ")}` : "";
1600
+ log2(` ${agent.id.padEnd(16)} ${status}${files}`);
1601
+ }
1602
+ return found;
1603
+ }
1604
+
1605
+ // src/setup.ts
1418
1606
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
1419
1607
  var DEFAULT_SERVER = "agent-hub";
1420
1608
  function defaultSkillSrc() {
1421
- return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "SKILL.md");
1609
+ return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1422
1610
  }
1423
1611
  function stamp() {
1424
1612
  const now = /* @__PURE__ */ new Date();
@@ -1426,7 +1614,7 @@ function stamp() {
1426
1614
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1427
1615
  }
1428
1616
  async function readJson(file) {
1429
- if (!existsSync(file)) return null;
1617
+ if (!existsSync2(file)) return null;
1430
1618
  try {
1431
1619
  return JSON.parse(await readFile(file, "utf8"));
1432
1620
  } catch (error) {
@@ -1434,7 +1622,7 @@ async function readJson(file) {
1434
1622
  }
1435
1623
  }
1436
1624
  async function writeJsonNoBom(file, doc) {
1437
- await mkdir(dirname(file), { recursive: true });
1625
+ await mkdir(dirname2(file), { recursive: true });
1438
1626
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
1439
1627
  }
1440
1628
  async function backup(file) {
@@ -1455,7 +1643,7 @@ function resolveSection(doc, section) {
1455
1643
  return node;
1456
1644
  }
1457
1645
  async function mergeJsonServer(file, section, entry, opts) {
1458
- if (!existsSync(file)) return "skipped";
1646
+ if (!existsSync2(file)) return "skipped";
1459
1647
  const doc = await readJson(file);
1460
1648
  if (doc === null) return "skipped";
1461
1649
  const servers = resolveSection(doc, section);
@@ -1477,7 +1665,7 @@ async function mergeJsonServer(file, section, entry, opts) {
1477
1665
  return "changed";
1478
1666
  }
1479
1667
  async function mergeTomlSection(file, opts) {
1480
- if (!existsSync(file)) return "skipped";
1668
+ if (!existsSync2(file)) return "skipped";
1481
1669
  const text = await readFile(file, "utf8");
1482
1670
  const marker = `[mcp_servers.${opts.serverName}]`;
1483
1671
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
@@ -1515,7 +1703,7 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
1515
1703
  `;
1516
1704
  }
1517
1705
  async function mergeDshPatch(file, opts) {
1518
- if (!existsSync(file)) return "skipped";
1706
+ if (!existsSync2(file)) return "skipped";
1519
1707
  const text = await readFile(file, "utf8");
1520
1708
  const lines = text.split("\n");
1521
1709
  const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
@@ -1565,29 +1753,40 @@ async function mergeDshPatch(file, opts) {
1565
1753
  }
1566
1754
  async function syncSkill(skillDir, skillSrc, remove, log2) {
1567
1755
  if (remove) {
1568
- if (existsSync(skillDir)) {
1569
- await mkdir(dirname(skillDir), { recursive: true });
1756
+ if (existsSync2(skillDir)) {
1757
+ await mkdir(dirname2(skillDir), { recursive: true });
1570
1758
  await rmRecursive(skillDir);
1571
1759
  log2(` skill removed: ${skillDir}`);
1572
1760
  }
1573
1761
  return;
1574
1762
  }
1575
- if (!existsSync(skillSrc)) {
1763
+ if (!existsSync2(skillSrc)) {
1576
1764
  log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
1577
1765
  return;
1578
1766
  }
1579
1767
  await mkdir(skillDir, { recursive: true });
1580
- await copyFile(skillSrc, join(skillDir, "SKILL.md"));
1581
- log2(` skill -> ${join(skillDir, "SKILL.md")}`);
1768
+ await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
1769
+ log2(` skill -> ${join2(skillDir, "SKILL.md")}`);
1582
1770
  }
1583
1771
  async function rmRecursive(dir) {
1584
1772
  const { rm } = await import("node:fs/promises");
1585
1773
  await rm(dir, { recursive: true, force: true });
1586
1774
  }
1775
+ function substitute(entry, values) {
1776
+ const out = {};
1777
+ for (const [key, value] of Object.entries(entry)) {
1778
+ if (typeof value === "string") {
1779
+ out[key] = value.replaceAll("{url}", values.url).replaceAll("{serverName}", values.serverName);
1780
+ } else {
1781
+ out[key] = value;
1782
+ }
1783
+ }
1784
+ return out;
1785
+ }
1587
1786
  async function runSetup(options = {}) {
1588
1787
  const url = options.url ?? DEFAULT_URL;
1589
1788
  const serverName = options.serverName ?? DEFAULT_SERVER;
1590
- const home = options.homeDir ?? homedir();
1789
+ const home = options.homeDir ?? homedir2();
1591
1790
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
1592
1791
  const remove = options.remove === true;
1593
1792
  const log2 = options.log ?? ((message) => console.log(message));
@@ -1597,65 +1796,40 @@ async function runSetup(options = {}) {
1597
1796
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
1598
1797
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
1599
1798
  };
1600
- const jsonTargets = [
1601
- { label: "mcode", file: join(home, ".minimax", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
1602
- { label: "mcode", file: join(home, ".minimax", "mcp", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
1603
- { label: "opencode", file: join(home, ".config", "opencode", "opencode.json"), section: "mcp", entry: { type: "remote", url, enabled: true } },
1604
- { label: "kimi-code", file: join(home, ".kimi-code", "mcp.json"), section: "mcpServers", entry: { transport: "http", url, startupTimeoutMs: 3e4, toolTimeoutMs: 12e4 } },
1605
- { label: "gemini-cli", file: join(home, ".gemini", "settings.json"), section: "mcpServers", entry: { type: "http", url } },
1606
- { label: "zcode", file: join(home, ".zcode", "cli", "config.json"), section: "mcp.servers", entry: { type: "remote", url, enabled: true } }
1607
- ];
1608
- for (const target of jsonTargets) {
1609
- try {
1610
- const status = await mergeJsonServer(target.file, target.section, target.entry, { serverName, url, remove });
1611
- record(status, target.label, target.file);
1612
- } catch (error) {
1613
- summary.errors.push(`${target.label}: ${target.file} \u2014 ${error.message}`);
1614
- log2(` ${target.label}: SKIPPED \u2014 ${error.message}`);
1615
- }
1616
- }
1617
- const codexFile = join(home, ".codex", "config.toml");
1618
- try {
1619
- const status = await mergeTomlSection(codexFile, { serverName, url, remove });
1620
- record(status, "codex", codexFile);
1621
- } catch (error) {
1622
- summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
1623
- log2(` codex: SKIPPED \u2014 ${error.message}`);
1624
- }
1625
- const dshProfilesDir = join(home, ".dsh", "profiles");
1626
- if (existsSync(dshProfilesDir)) {
1627
- let entries = [];
1628
- try {
1629
- entries = await readdir(dshProfilesDir, { withFileTypes: true });
1630
- } catch (error) {
1631
- summary.errors.push(`dsh profiles scan \u2014 ${error.message}`);
1799
+ const registry = loadRegistry();
1800
+ const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
1801
+ const only = options.agent;
1802
+ let targetAgents = [];
1803
+ if (only !== void 0) {
1804
+ const match = registry.agents.find((agent) => agent.id === only);
1805
+ if (match === void 0) {
1806
+ log2(`agent '${only}' is not in the registry (see agents/registry.json)`);
1807
+ } else {
1808
+ targetAgents = [match];
1809
+ log2(`configure only: ${only}`);
1632
1810
  }
1633
- for (const entry of entries) {
1634
- if (!entry.isDirectory()) continue;
1635
- const patch = join(dshProfilesDir, entry.name, "cordis.patch.yml");
1636
- try {
1637
- const status = await mergeDshPatch(patch, { serverName, url, remove });
1638
- record(status, `dsh (${entry.name})`, patch);
1639
- } catch (error) {
1640
- summary.errors.push(`dsh (${entry.name}): ${patch} \u2014 ${error.message}`);
1641
- log2(` dsh (${entry.name}): SKIPPED \u2014 ${error.message}`);
1811
+ } else {
1812
+ targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
1813
+ const present2 = targetAgents.map((agent) => agent.id);
1814
+ log2(`discovered: ${present2.length > 0 ? present2.join(", ") : "none"}`);
1815
+ }
1816
+ for (const agent of targetAgents) {
1817
+ for (const config of agent.configs) {
1818
+ for (const file of expandConfigFile(config.file, home)) {
1819
+ try {
1820
+ const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName }), { serverName, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName, url, remove }) : await mergeDshPatch(file, { serverName, url, remove });
1821
+ record(status, agent.id, file);
1822
+ } catch (error) {
1823
+ summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
1824
+ log2(` ${agent.id}: SKIPPED \u2014 ${error.message}`);
1825
+ }
1642
1826
  }
1643
1827
  }
1644
1828
  }
1645
- const skillDirs = [
1646
- join(home, ".agents", "skills", serverName),
1647
- // cross-agent standard
1648
- join(home, ".minimax", "skills", serverName),
1649
- join(home, ".config", "opencode", "skills", serverName),
1650
- join(home, ".kimi-code", "skills", serverName),
1651
- join(home, ".gemini", "skills", serverName),
1652
- join(home, ".codex", "skills", serverName),
1653
- join(home, ".zcode", "skills", serverName),
1654
- join(home, ".claude", "skills", serverName),
1655
- // config is manual; skill still useful
1656
- join(home, ".dsh", "skills", serverName)
1657
- // DSH skill (config auto-installed)
1658
- ];
1829
+ const skillDirs = [join2(home, ".agents", "skills", serverName)];
1830
+ for (const agent of targetAgents) {
1831
+ if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
1832
+ }
1659
1833
  for (const dir of skillDirs) {
1660
1834
  try {
1661
1835
  await syncSkill(dir, skillSrc, remove, log2);
@@ -1670,12 +1844,12 @@ async function runSetup(options = {}) {
1670
1844
  }
1671
1845
 
1672
1846
  // src/ops.ts
1673
- import { execFileSync } from "node:child_process";
1847
+ import { execFileSync as execFileSync2 } from "node:child_process";
1674
1848
  import { request } from "node:http";
1675
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1676
- import { homedir as homedir2 } from "node:os";
1677
- import { dirname as dirname2, join as join2 } from "node:path";
1678
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1849
+ import { mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
1850
+ import { homedir as homedir3 } from "node:os";
1851
+ import { dirname as dirname3, join as join3 } from "node:path";
1852
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1679
1853
  async function runStatus(options = {}) {
1680
1854
  const host = options.host ?? "127.0.0.1";
1681
1855
  const port = options.port ?? 18764;
@@ -1727,8 +1901,8 @@ async function runStatus(options = {}) {
1727
1901
  function runUpdate() {
1728
1902
  const messages = [];
1729
1903
  try {
1730
- const pkgFile = join2(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
1731
- const before = JSON.parse(readFileSync(pkgFile, "utf8")).version ?? "?";
1904
+ const pkgFile = join3(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
1905
+ const before = JSON.parse(readFileSync2(pkgFile, "utf8")).version ?? "?";
1732
1906
  messages.push(`current version: ${before}`);
1733
1907
  const script = [
1734
1908
  "import { execFileSync } from 'node:child_process'",
@@ -1744,7 +1918,7 @@ function runUpdate() {
1744
1918
  "if (after === before) console.log('already up to date (v' + after + ')')",
1745
1919
  "else console.log('updated: v' + before + ' -> v' + after)"
1746
1920
  ].join("\n");
1747
- const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], { encoding: "utf8", windowsHide: true });
1921
+ const out = execFileSync2(process.execPath, ["--input-type=module", "-e", script], { encoding: "utf8", windowsHide: true });
1748
1922
  messages.push(out.trim());
1749
1923
  messages.push("restart the hub (agent-comm-hub) to pick up the new version");
1750
1924
  return { ok: true, messages };
@@ -1753,14 +1927,14 @@ function runUpdate() {
1753
1927
  }
1754
1928
  }
1755
1929
  function cliPath() {
1756
- return fileURLToPath2(import.meta.url);
1930
+ return fileURLToPath3(import.meta.url);
1757
1931
  }
1758
1932
  function nodeExe() {
1759
1933
  return process.execPath;
1760
1934
  }
1761
1935
  function run(command, args, dryRun) {
1762
1936
  if (dryRun) return `[dry-run] ${command} ${args.join(" ")}`;
1763
- return execFileSync(command, args, { encoding: "utf8", windowsHide: true }).trim();
1937
+ return execFileSync2(command, args, { encoding: "utf8", windowsHide: true }).trim();
1764
1938
  }
1765
1939
  function runService(options) {
1766
1940
  const messages = [];
@@ -1768,11 +1942,12 @@ function runService(options) {
1768
1942
  const host = options.host ?? "127.0.0.1";
1769
1943
  const path2 = options.path ?? "/mcp";
1770
1944
  const dryRun = options.dryRun === true;
1945
+ const platform = options.platform ?? process.platform;
1771
1946
  try {
1772
- if (process.platform === "win32") {
1773
- const appData = process.env.APPDATA ?? join2(homedir2(), "AppData", "Roaming");
1774
- const launcherDir = join2(appData, "agent-comm-hub");
1775
- const vbs = join2(launcherDir, "agent-comm-hub.vbs");
1947
+ if (platform === "win32") {
1948
+ const appData = process.env.APPDATA ?? join3(homedir3(), "AppData", "Roaming");
1949
+ const launcherDir = join3(appData, "agent-comm-hub");
1950
+ const vbs = join3(launcherDir, "agent-comm-hub.vbs");
1776
1951
  const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
1777
1952
  const valueName = "agent-comm-hub";
1778
1953
  if (options.action === "install") {
@@ -1785,7 +1960,7 @@ function runService(options) {
1785
1960
  } else {
1786
1961
  mkdirSync(launcherDir, { recursive: true });
1787
1962
  writeFileSync(vbs, vbsContent);
1788
- execFileSync("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1963
+ execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1789
1964
  messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
1790
1965
  messages.push(`start it now with: wscript.exe "${vbs}"`);
1791
1966
  }
@@ -1795,7 +1970,7 @@ function runService(options) {
1795
1970
  messages.push(`[dry-run] del ${vbs}`);
1796
1971
  } else {
1797
1972
  try {
1798
- execFileSync("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
1973
+ execFileSync2("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
1799
1974
  } catch {
1800
1975
  }
1801
1976
  rmSync(launcherDir, { recursive: true, force: true });
@@ -1804,9 +1979,9 @@ function runService(options) {
1804
1979
  }
1805
1980
  return { ok: true, messages };
1806
1981
  }
1807
- if (process.platform === "linux") {
1808
- const unitDir = join2(homedir2(), ".config", "systemd", "user");
1809
- const unitFile = join2(unitDir, "agent-comm-hub.service");
1982
+ if (platform === "linux") {
1983
+ const unitDir = join3(homedir3(), ".config", "systemd", "user");
1984
+ const unitFile = join3(unitDir, "agent-comm-hub.service");
1810
1985
  if (options.action === "install") {
1811
1986
  const unit = `[Unit]
1812
1987
  Description=agent-comm-hub (multi-peer MCP hub)
@@ -1841,7 +2016,72 @@ WantedBy=default.target
1841
2016
  }
1842
2017
  return { ok: true, messages };
1843
2018
  }
1844
- return { ok: false, messages: [`auto-start is not implemented for ${process.platform} \u2014 use pm2 or your platform's supervisor`] };
2019
+ if (platform === "darwin") {
2020
+ const launchAgentsDir = join3(homedir3(), "Library", "LaunchAgents");
2021
+ const label = "com.agent-comm-hub";
2022
+ const plist = join3(launchAgentsDir, `${label}.plist`);
2023
+ const logFile = join3(homedir3(), "Library", "Logs", "agent-comm-hub.log");
2024
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
2025
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
2026
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2027
+ <plist version="1.0">
2028
+ <dict>
2029
+ <key>Label</key>
2030
+ <string>${label}</string>
2031
+ <key>ProgramArguments</key>
2032
+ <array>
2033
+ <string>${nodeExe()}</string>
2034
+ <string>${cliPath()}</string>
2035
+ <string>--host</string><string>${host}</string>
2036
+ <string>--port</string><string>${port}</string>
2037
+ <string>--path</string><string>${path2}</string>
2038
+ </array>
2039
+ <key>RunAtLoad</key>
2040
+ <true/>
2041
+ <key>KeepAlive</key>
2042
+ <true/>
2043
+ <key>StandardOutPath</key>
2044
+ <string>${logFile}</string>
2045
+ <key>StandardErrorPath</key>
2046
+ <string>${logFile}</string>
2047
+ </dict>
2048
+ </plist>
2049
+ `;
2050
+ if (options.action === "install") {
2051
+ if (dryRun) {
2052
+ messages.push(`[dry-run] write ${plist}`);
2053
+ messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
2054
+ } else {
2055
+ mkdirSync(launchAgentsDir, { recursive: true });
2056
+ writeFileSync(plist, plistContent);
2057
+ try {
2058
+ execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
2059
+ } catch {
2060
+ execFileSync2("launchctl", ["load", "-w", plist], { encoding: "utf8", windowsHide: true });
2061
+ }
2062
+ messages.push(`auto-start registered: launchd LaunchAgent ${plist}`);
2063
+ messages.push(`start it now with: launchctl bootstrap gui/${uid} ${plist}`);
2064
+ }
2065
+ } else {
2066
+ if (dryRun) {
2067
+ messages.push(`[dry-run] launchctl bootout gui/${uid}/${label}`);
2068
+ messages.push(`[dry-run] rm ${plist}`);
2069
+ } else {
2070
+ try {
2071
+ execFileSync2("launchctl", ["bootout", `gui/${uid}/${label}`], { encoding: "utf8", windowsHide: true });
2072
+ } catch {
2073
+ try {
2074
+ execFileSync2("launchctl", ["unload", "-w", plist], { encoding: "utf8", windowsHide: true });
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ rmSync(plist, { force: true });
2079
+ messages.push("auto-start removed (launchd LaunchAgent)");
2080
+ }
2081
+ }
2082
+ return { ok: true, messages };
2083
+ }
2084
+ return { ok: false, messages: [`auto-start is not implemented for ${platform} \u2014 use pm2 or your platform's supervisor`] };
1845
2085
  } catch (error) {
1846
2086
  return { ok: false, messages: [`${error.message}`] };
1847
2087
  }
@@ -1851,7 +2091,7 @@ WantedBy=default.target
1851
2091
  function parseArgs(argv) {
1852
2092
  const args = {};
1853
2093
  const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--herdr-timeout-ms"]);
1854
- const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--herdr-bin"]);
2094
+ const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin"]);
1855
2095
  for (let i = 0; i < argv.length; i++) {
1856
2096
  const flag = argv[i];
1857
2097
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
@@ -1887,10 +2127,13 @@ Usage:
1887
2127
  every installed agent (incremental,
1888
2128
  idempotent; --remove undoes)
1889
2129
  agent-comm-hub status [options] show hub health + online peers
2130
+ agent-comm-hub discover list installed agents (registry-
2131
+ driven; no config changes)
1890
2132
  agent-comm-hub service install|uninstall [options]
1891
2133
  one-shot auto-start (Windows Run
1892
2134
  key + hidden VBS launcher, no admin;
1893
- Linux systemd; --dry-run prints)
2135
+ Linux systemd, macOS launchd;
2136
+ --dry-run prints)
1894
2137
  agent-comm-hub update self-update from the npm registry
1895
2138
  (files updated in place; restart
1896
2139
  the hub afterwards)
@@ -1900,7 +2143,7 @@ Hub options:
1900
2143
  --port <n> Listen port (default 18764)
1901
2144
  --path <p> MCP endpoint path (default /mcp)
1902
2145
  --max-queue <n> Queued messages per peer before dropping oldest (default 200)
1903
- --history-limit <n> Retained history messages (default 100)
2146
+ --history-limit <n> Retained history messages (default 1000)
1904
2147
  --wait-timeout-ms <n> Long-poll ceiling for bridge_wait (default 60000)
1905
2148
  --default-wait-ms <n> bridge_wait default budget (default 30000)
1906
2149
  --connected-window-ms <n> Peer counts as active within this window (default 30000)
@@ -1912,6 +2155,7 @@ Hub options:
1912
2155
  Setup options:
1913
2156
  --url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
1914
2157
  --server-name <name> Config key (default agent-hub)
2158
+ --agent <id> Only configure one registry agent (e.g. codex)
1915
2159
  --remove Uninstall instead of install
1916
2160
 
1917
2161
  -h, --help Show this help
@@ -1936,11 +2180,16 @@ try {
1936
2180
  await runSetup({
1937
2181
  url: args2["--url"],
1938
2182
  serverName: args2["--server-name"],
2183
+ agent: args2["--agent"],
1939
2184
  remove: args2["--remove"] === true,
1940
2185
  log: (message) => log.info(message)
1941
2186
  });
1942
2187
  process.exit(0);
1943
2188
  }
2189
+ if (command === "discover") {
2190
+ runDiscover({ log: (message) => log.info(message) });
2191
+ process.exit(0);
2192
+ }
1944
2193
  if (command === "status") {
1945
2194
  const args2 = parseArgs(rest);
1946
2195
  const result = await runStatus({