Skill: imgolye/agent-communication-hub (version 0.1.0)

--- name: agent-communication-hub description: Provide agent-to-agent communication for OpenClaw skills with direct messaging, broadcast delivery, pub/sub events, session tracking, offline queues, and SQLite-backed persistence. Use when agents need reliable message exchange, event fan-out, subscription filtering, or communication history. --- # Agent Communication Hub Use this skill when multiple agents need a shared communication layer with durable delivery and session awareness. ## What It Provides - Point-to-point, private, and broadcast messaging - Event publish/subscribe with subscription filters - Agent registration, presence tracking, and session history - Offline message queueing, persistence, and acknowledgements - SQLite-backed audit history for messages and events ## Project Layout - `src/CommunicationHub.ts`: Main entry point that coordinates messaging, storage, queueing, and acknowledgements - `src/EventBus.ts`: Event publish/subscribe, replay, and filter evaluation - `src/SessionManager.ts`: Agent lifecycle, presence, and session history - `tests/`: Vitest coverage for messaging, sessions, queueing, and events - `examples/`: Minimal runnable example ## Workflow 1. Create a `CommunicationHub` with a SQLite database path. 2. Register agents through `SessionManager`. 3. Connect or disconnect agents to update presence state. 4. Send direct, private, or broadcast messages through `CommunicationHub`. 5. Subscribe agents to event types through `EventBus`. 6. Publish events and replay history when needed. 7. Acknowledge delivered messages to complete queue processing. ## Notes - Broadcast delivery fans out to all registered agents except the sender. - Offline direct messages remain queued until the recipient connects or explicitly drains the queue. - Event filters use exact key/value matching against event payload fields. - Message and event payloads are stored as JSON for flexible schemas.

path: README.md
content: # agent-communication-hub

`agent-communication-hub` is an OpenClaw skill and TypeScript library for agent-to-agent communication. It provides durable messaging, pub/sub events, session presence, and offline queue handling backed by SQLite.

## Features

- Direct, private, and broadcast messaging
- Event publish/subscribe with payload filtering
- Agent registration, online state, and session history
- Offline message queueing with delivery acknowledgement
- SQLite persistence for audit and replay

## Installation

```bash
npm install
```

## Usage

```ts
import { CommunicationHub } from "./src/CommunicationHub.js";

const hub = new CommunicationHub({ dbPath: "./agent-hub.sqlite" });

hub.sessions.registerAgent({ id: "planner" });
hub.sessions.registerAgent({ id: "worker" });

hub.sessions.connectAgent({ agentId: "planner" });

const message = hub.sendDirectMessage({
  senderId: "planner",
  recipientId: "worker",
  payload: { taskId: "task-1" },
});

console.log(message.status); // pending when worker is offline
```

## API Overview

### `CommunicationHub`

- `sendDirectMessage()`: send a point-to-point message
- `sendPrivateMessage()`: send a private message with the same delivery semantics as direct messaging
- `broadcastMessage()`: fan out a message to all registered agents except the sender
- `getPendingMessages()`: inspect offline queue state
- `drainOfflineQueue()`: deliver queued messages after reconnect
- `acknowledgeMessage()`: mark a delivered message as processed
- `listMessages()`: query message history

### `SessionManager`

- `registerAgent()`: register or update agent metadata
- `connectAgent()`: mark an agent online and create a session record
- `disconnectAgent()`: mark an agent offline and close live sessions
- `listAgents()`: inspect known agents
- `getSessionHistory()`: review past sessions

### `EventBus`

- `subscribe()`: subscribe an agent to an event type with exact-match payload filters
- `unsubscribe()`: remove a subscription
- `publish()`: persist an event and resolve matching recipients
- `replay()`: query historical events for tracing or recovery

## Development

```bash
npm run build
npm test
npm run example
```


path: examples/basic-usage.ts
content: import { CommunicationHub } from "../src/CommunicationHub.js";

const hub = new CommunicationHub({ dbPath: "./agent-hub.sqlite" });

hub.sessions.registerAgent({ id: "planner", name: "Planner Agent", metadata: { role: "planner" } });
hub.sessions.registerAgent({ id: "executor", name: "Executor Agent", metadata: { role: "executor" } });
hub.sessions.registerAgent({ id: "observer", name: "Observer Agent", metadata: { role: "observer" } });

hub.sessions.connectAgent({ agentId: "planner" });
hub.sessions.connectAgent({ agentId: "executor" });

hub.events.subscribe("observer", "task.created", { priority: "high" });

const direct = hub.sendDirectMessage({
  senderId: "planner",
  recipientId: "executor",
  payload: { taskId: "task-42", action: "analyze" },
  topic: "task.assign",
});

const eventResult = hub.events.publish({
  type: "task.created",
  sourceAgentId: "planner",
  payload: { taskId: "task-42", priority: "high" },
  metadata: { traceId: "trace-1" },
});

console.log("Direct message:", direct);
console.log("Event recipients:", eventResult.recipients);
console.log("Replay:", hub.events.replay({ type: "task.created" }));

hub.close();


path: package.json
content: {
  "name": "agent-communication-hub",
  "version": "0.1.0",
  "description": "OpenClaw skill for agent-to-agent communication with SQLite persistence.",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist",
    "SKILL.md",
    "README.md"
  ],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "clean": "rm -rf dist coverage .vitest",
    "example": "tsx examples/basic-usage.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "keywords": [
    "openclaw",
    "skill",
    "agent",
    "communication",
    "sqlite"
  ],
  "license": "MIT",
  "dependencies": {
    "better-sqlite3": "^11.10.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.13",
    "@types/node": "^24.0.0",
    "tsx": "^4.20.5",
    "typescript": "^5.8.2",
    "vitest": "^3.0.8"
  }
}


path: src/CommunicationHub.ts
content: import { openDatabase, fromJson, createId, nowIso, toJson, type SqliteDatabase } from "./db.js";
import { EventBus } from "./EventBus.js";
import { SessionManager } from "./SessionManager.js";
import type { MessageKind, MessageQuery, MessageRecord, SendMessageInput } from "./types.js";

export interface CommunicationHubOptions {
  dbPath?: string;
  db?: SqliteDatabase;
}

export class CommunicationHub {
  public readonly db: SqliteDatabase;
  public readonly sessions: SessionManager;
  public readonly events: EventBus;

  public constructor(options: CommunicationHubOptions = {}) {
    this.db = options.db ?? openDatabase(options.dbPath ?? ":memory:");
    this.sessions = new SessionManager(this.db);
    this.events = new EventBus(this.db, this.sessions);
  }

  public sendDirectMessage(input: SendMessageInput): MessageRecord {
    if (!input.recipientId) {
      throw new Error("recipientId is required for direct messages");
    }

    return this.createMessage("direct", input.senderId, input.recipientId, input.payload, input.topic, input.correlationId);
  }

  public sendPrivateMessage(input: SendMessageInput): MessageRecord {
    if (!input.recipientId) {
      throw new Error("recipientId is required for private messages");
    }

    return this.createMessage("private", input.senderId, input.recipientId, input.payload, input.topic, input.correlationId);
  }

  public broadcastMessage(input: Omit<SendMessageInput, "recipientId">): MessageRecord[] {
    this.sessions.getAgent(input.senderId);
    const recipients = this.sessions
      .listAgents()
      .filter((agent) => agent.id !== input.senderId)
      .map((agent) => agent.id);

    return recipients.map((recipientId) =>
      this.createMessage("broadcast", input.senderId, recipientId, input.payload, input.topic, input.correlationId),
    );
  }

  public acknowledgeMessage(messageId: string): MessageRecord {
    const record = this.getMessage(messageId);
    const acknowledgedAt = nowIso();

    this.db
      .prepare(
        `UPDATE messages
         SET status = 'acknowledged', acknowledged_at = ?
         WHERE id = ?`,
      )
      .run(acknowledgedAt, messageId);

    return this.getMessage(messageId);
  }

  public drainOfflineQueue(agentId: string): MessageRecord[] {
    this.sessions.getAgent(agentId);
    if (!this.sessions.isAgentOnline(agentId)) {
      return [];
    }

    const deliveredAt = nowIso();
    this.db
      .prepare(
        `UPDATE messages
         SET status = 'delivered', delivered_at = ?
         WHERE recipient_id = ? AND status = 'pending'`,
      )
      .run(deliveredAt, agentId);

    return this.listMessages({ recipientId: agentId, status: "delivered" });
  }

  public getPendingMessages(agentId: string): MessageRecord[] {
    return this.listMessages({ recipientId: agentId, status: "pending" });
  }

  public listMessages(query: MessageQuery = {}): MessageRecord[] {
    const conditions: string[] = [];
    const values: Array<string | number> = [];

    if (query.senderId) {
      conditions.push("sender_id = ?");
      values.push(query.senderId);
    }
    if (query.recipientId) {
      conditions.push("recipient_id = ?");
      values.push(query.recipientId);
    }
    if (query.status) {
      conditions.push("status = ?");
      values.push(query.status);
    }
    if (query.kind) {
      conditions.push("kind = ?");
      values.push(query.kind);
    }

    const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
    const limit = query.limit ?? 100;
    const rows = this.db
      .prepare(`SELECT * FROM messages ${where} ORDER BY created_at DESC LIMIT ?`)
      .all(...values, limit) as Array<Record<string, string | null>>;

    return rows.map(mapMessageRow);
  }

  public close(): void {
    this.db.close();
  }

  private createMessage(
    kind: MessageKind,
    senderId: string,
    recipientId: string,
    payload: Record<string, unknown>,
    topic?: string,
    correlationId?: string,
  ): MessageRecord {
    this.sessions.getAgent(senderId);
    this.sessions.getAgent(recipientId);

    const recipientOnline = this.sessions.isAgentOnline(recipientId);
    const timestamp = nowIso();
    const messageId = createId("msg");
    const status = recipientOnline ? "delivered" : "pending";
    const deliveredAt = recipientOnline ? timestamp : null;

    this.db
      .prepare(
        `INSERT INTO messages (
           id, kind, sender_id, recipient_id, payload_json, status, topic,
           created_at, delivered_at, acknowledged_at, correlation_id
         )
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
      )
      .run(
        messageId,
        kind,
        senderId,
        recipientId,
        toJson(payload),
        status,
        topic ?? null,
        timestamp,
        deliveredAt,
        correlationId ?? null,
      );

    return this.getMessage(messageId);
  }

  private getMessage(messageId: string): MessageRecord {
    const row = this.db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as Record<string, string | null> | undefined;
    if (!row) {
      throw new Error(`Message not found: ${messageId}`);
    }

    return mapMessageRow(row);
  }
}

function mapMessageRow(row: Record<string, string | null>): MessageRecord {
  return {
    id: row.id as string,
    kind: row.kind as MessageRecord["kind"],
    senderId: row.sender_id as string,
    recipientId: row.recipient_id as string | null,
    payload: fromJson(row.payload_json as string),
    status: row.status as MessageRecord["status"],
    topic: row.topic as string | null,
    createdAt: row.created_at as string,
    deliveredAt: row.delivered_at as string | null,
    acknowledgedAt: row.acknowledged_at as string | null,
    correlationId: row.correlation_id as string | null,
  };
}


path: src/EventBus.ts
content: import type { EventPublishInput, EventRecord, EventReplayOptions, EventSubscription } from "./types.js";
import { createId, fromJson, nowIso, toJson, type SqliteDatabase } from "./db.js";
import { SessionManager } from "./SessionManager.js";

export class EventBus {
  public constructor(
    private readonly db: SqliteDatabase,
    private readonly sessions: SessionManager,
  ) {}

  public subscribe(agentId: string, eventType: string, filter: Record<string, unknown> = {}): EventSubscription {
    this.sessions.getAgent(agentId);

    const id = createId("sub");
    const createdAt = nowIso();
    this.db
      .prepare(
        `INSERT INTO event_subscriptions (id, agent_id, event_type, filter_json, created_at)
         VALUES (?, ?, ?, ?, ?)`,
      )
      .run(id, agentId, eventType, toJson(filter), createdAt);

    return { id, agentId, eventType, filter, createdAt };
  }

  public unsubscribe(subscriptionId: string): void {
    this.db.prepare("DELETE FROM event_subscriptions WHERE id = ?").run(subscriptionId);
  }

  public publish(input: EventPublishInput): { event: EventRecord; recipients: string[] } {
    if (input.sourceAgentId) {
      this.sessions.getAgent(input.sourceAgentId);
    }

    const event: EventRecord = {
      id: createId("evt"),
      type: input.type,
      sourceAgentId: input.sourceAgentId ?? null,
      payload: input.payload,
      metadata: input.metadata ?? {},
      createdAt: nowIso(),
    };

    this.db
      .prepare(
        `INSERT INTO events (id, type, source_agent_id, payload_json, metadata_json, created_at)
         VALUES (?, ?, ?, ?, ?, ?)`,
      )
      .run(
        event.id,
        event.type,
        event.sourceAgentId,
        toJson(event.payload),
        toJson(event.metadata),
        event.createdAt,
      );

    const rows = this.db
      .prepare("SELECT * FROM event_subscriptions WHERE event_type = ? ORDER BY created_at ASC")
      .all(event.type) as Array<Record<string, string>>;

    const recipients = rows
      .filter((row) => matchesFilter(fromJson(row.filter_json), event.payload))
      .map((row) => row.agent_id);

    return { event, recipients };
  }

  public replay(options: EventReplayOptions = {}): EventRecord[] {
    const conditions: string[] = [];
    const values: Array<string | number> = [];

    if (options.type) {
      conditions.push("type = ?");
      values.push(options.type);
    }
    if (options.sourceAgentId) {
      conditions.push("source_agent_id = ?");
      values.push(options.sourceAgentId);
    }
    if (options.since) {
      conditions.push("created_at >= ?");
      values.push(options.since);
    }

    const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
    const limit = options.limit ?? 100;
    const sql = `SELECT * FROM events ${where} ORDER BY created_at DESC LIMIT ?`;
    const rows = this.db.prepare(sql).all(...values, limit) as Array<Record<string, string | null>>;

    return rows.map((row) => ({
      id: row.id as string,
      type: row.type as string,
      sourceAgentId: row.source_agent_id as string | null,
      payload: fromJson(row.payload_json as string),
      metadata: fromJson(row.metadata_json as string),
      createdAt: row.created_at as string,
    }));
  }

  public listSubscriptions(agentId?: string): EventSubscription[] {
    const rows = agentId
      ? (this.db
          .prepare("SELECT * FROM event_subscriptions WHERE agent_id = ? ORDER BY created_at ASC")
          .all(agentId) as Array<Record<string, string>>)
      : (this.db.prepare("SELECT * FROM event_subscriptions ORDER BY created_at ASC").all() as Array<Record<string, string>>);

    return rows.map((row) => ({
      id: row.id,
      agentId: row.agent_id,
      eventType: row.event_type,
      filter: fromJson(row.filter_json),
      createdAt: row.created_at,
    }));
  }
}

function matchesFilter(filter: Record<string, unknown>, payload: Record<string, unknown>): boolean {
  return Object.entries(filter).every(([key, value]) => payload[key] === value);
}


path: src/SessionManager.ts
content: import type { AgentRecord, AgentRegistrationInput, SessionConnectInput, SessionRecord } from "./types.js";
import { createId, fromJson, nowIso, toJson, type SqliteDatabase } from "./db.js";

export class SessionManager {
  public constructor(private readonly db: SqliteDatabase) {}

  public registerAgent(input: AgentRegistrationInput): AgentRecord {
    const existing = this.db
      .prepare("SELECT * FROM agents WHERE id = ?")
      .get(input.id) as Record<string, string> | undefined;

    const timestamp = nowIso();
    const metadata = input.metadata ?? {};
    const name = input.name ?? input.id;

    if (existing) {
      this.db
        .prepare(
          `UPDATE agents
           SET name = ?, metadata_json = ?, updated_at = ?, last_seen_at = ?
           WHERE id = ?`,
        )
        .run(name, toJson(metadata), timestamp, timestamp, input.id);
    } else {
      this.db
        .prepare(
          `INSERT INTO agents (id, name, status, metadata_json, last_seen_at, created_at, updated_at)
           VALUES (?, ?, 'offline', ?, ?, ?, ?)`,
        )
        .run(input.id, name, toJson(metadata), timestamp, timestamp, timestamp);
    }

    return this.getAgent(input.id);
  }

  public connectAgent(input: SessionConnectInput): SessionRecord {
    this.ensureAgentExists(input.agentId);
    const sessionId = createId("session");
    const timestamp = nowIso();

    this.db
      .prepare(
        `INSERT INTO sessions (id, agent_id, status, connected_at, disconnected_at, metadata_json)
         VALUES (?, ?, 'connected', ?, NULL, ?)`,
      )
      .run(sessionId, input.agentId, timestamp, toJson(input.metadata ?? {}));

    this.db
      .prepare(
        `UPDATE agents
         SET status = 'online', last_seen_at = ?, updated_at = ?
         WHERE id = ?`,
      )
      .run(timestamp, timestamp, input.agentId);

    return this.getSession(sessionId);
  }

  public disconnectAgent(agentId: string): void {
    this.ensureAgentExists(agentId);
    const timestamp = nowIso();

    this.db
      .prepare(
        `UPDATE sessions
         SET status = 'disconnected', disconnected_at = ?
         WHERE agent_id = ? AND status = 'connected'`,
      )
      .run(timestamp, agentId);

    this.db
      .prepare(
        `UPDATE agents
         SET status = 'offline', last_seen_at = ?, updated_at = ?
         WHERE id = ?`,
      )
      .run(timestamp, timestamp, agentId);
  }

  public getAgent(agentId: string): AgentRecord {
    const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(agentId) as Record<string, string> | undefined;
    if (!row) {
      throw new Error(`Agent not found: ${agentId}`);
    }

    return {
      id: row.id,
      name: row.name,
      status: row.status as AgentRecord["status"],
      metadata: fromJson(row.metadata_json),
      lastSeenAt: row.last_seen_at,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    };
  }

  public listAgents(): AgentRecord[] {
    const rows = this.db.prepare("SELECT * FROM agents ORDER BY created_at ASC").all() as Array<Record<string, string>>;
    return rows.map((row) => ({
      id: row.id,
      name: row.name,
      status: row.status as AgentRecord["status"],
      metadata: fromJson(row.metadata_json),
      lastSeenAt: row.last_seen_at,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    }));
  }

  public getSessionHistory(agentId: string): SessionRecord[] {
    this.ensureAgentExists(agentId);
    const rows = this.db
      .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY connected_at DESC")
      .all(agentId) as Array<Record<string, string | null>>;

    return rows.map((row) => ({
      id: row.id as string,
      agentId: row.agent_id as string,
      status: row.status as SessionRecord["status"],
      connectedAt: row.connected_at as string,
      disconnectedAt: row.disconnected_at as string | null,
      metadata: fromJson(row.metadata_json as string),
    }));
  }

  public isAgentOnline(agentId: string): boolean {
    return this.getAgent(agentId).status === "online";
  }

  private getSession(sessionId: string): SessionRecord {
    const row = this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId) as Record<string, string | null> | undefined;
    if (!row) {
      throw new Error(`Session not found: ${sessionId}`);
    }

    return {
      id: row.id as string,
      agentId: row.agent_id as string,
      status: row.status as SessionRecord["status"],
      connectedAt: row.connected_at as string,
      disconnectedAt: row.disconnected_at as string | null,
      metadata: fromJson(row.metadata_json as string),
    };
  }

  private ensureAgentExists(agentId: string): void {
    const count = this.db.prepare("SELECT COUNT(*) as count FROM agents WHERE id = ?").get(agentId) as { count: number };
    if (count.count === 0) {
      throw new Error(`Agent not registered: ${agentId}`);
    }
  }
}


path: src/db.ts
content: import Database from "better-sqlite3";

export type SqliteDatabase = Database.Database;

export function openDatabase(filename: string): SqliteDatabase {
  const db = new Database(filename);
  db.pragma("journal_mode = WAL");
  db.pragma("foreign_keys = ON");
  initializeSchema(db);
  return db;
}

function initializeSchema(db: SqliteDatabase): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS agents (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      status TEXT NOT NULL,
      metadata_json TEXT NOT NULL,
      last_seen_at TEXT NOT NULL,
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS sessions (
      id TEXT PRIMARY KEY,
      agent_id TEXT NOT NULL,
      status TEXT NOT NULL,
      connected_at TEXT NOT NULL,
      disconnected_at TEXT,
      metadata_json TEXT NOT NULL,
      FOREIGN KEY (agent_id) REFERENCES agents(id)
    );

    CREATE TABLE IF NOT EXISTS messages (
      id TEXT PRIMARY KEY,
      kind TEXT NOT NULL,
      sender_id TEXT NOT NULL,
      recipient_id TEXT,
      payload_json TEXT NOT NULL,
      status TEXT NOT NULL,
      topic TEXT,
      created_at TEXT NOT NULL,
      delivered_at TEXT,
      acknowledged_at TEXT,
      correlation_id TEXT,
      FOREIGN KEY (sender_id) REFERENCES agents(id),
      FOREIGN KEY (recipient_id) REFERENCES agents(id)
    );

    CREATE TABLE IF NOT EXISTS events (
      id TEXT PRIMARY KEY,
      type TEXT NOT NULL,
      source_agent_id TEXT,
      payload_json TEXT NOT NULL,
      metadata_json TEXT NOT NULL,
      created_at TEXT NOT NULL,
      FOREIGN KEY (source_agent_id) REFERENCES agents(id)
    );

    CREATE TABLE IF NOT EXISTS event_subscriptions (
      id TEXT PRIMARY KEY,
      agent_id TEXT NOT NULL,
      event_type TEXT NOT NULL,
      filter_json TEXT NOT NULL,
      created_at TEXT NOT NULL,
      FOREIGN KEY (agent_id) REFERENCES agents(id)
    );

    CREATE INDEX IF NOT EXISTS idx_messages_recipient_status
      ON messages(recipient_id, status, created_at);
    CREATE INDEX IF NOT EXISTS idx_messages_sender
      ON messages(sender_id, created_at);
    CREATE INDEX IF NOT EXISTS idx_events_type_created
      ON events(type, created_at);
    CREATE INDEX IF NOT EXISTS idx_subscriptions_agent_type
      ON event_subscriptions(agent_id, event_type);
  `);
}

export function nowIso(): string {
  return new Date().toISOString();
}

export function createId(prefix: string): string {
  return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}

export function toJson(value: Record<string, unknown>): string {
  return JSON.stringify(value);
}

export function fromJson(value: string): Record<string, unknown> {
  return JSON.parse(value) as Record<string, unknown>;
}


path: src/index.ts
content: export { CommunicationHub } from "./CommunicationHub.js";
export { EventBus } from "./EventBus.js";
export { SessionManager } from "./SessionManager.js";
export type * from "./types.js";


path: src/types.ts
content: export type AgentStatus = "online" | "offline";

export type MessageKind = "direct" | "private" | "broadcast";

export type MessageStatus = "pending" | "delivered" | "acknowledged";

export interface AgentRecord {
  id: string;
  name: string;
  status: AgentStatus;
  metadata: Record<string, unknown>;
  lastSeenAt: string;
  createdAt: string;
  updatedAt: string;
}

export interface SessionRecord {
  id: string;
  agentId: string;
  status: "connected" | "disconnected";
  connectedAt: string;
  disconnectedAt: string | null;
  metadata: Record<string, unknown>;
}

export interface MessageRecord {
  id: string;
  kind: MessageKind;
  senderId: string;
  recipientId: string | null;
  payload: Record<string, unknown>;
  status: MessageStatus;
  topic: string | null;
  createdAt: string;
  deliveredAt: string | null;
  acknowledgedAt: string | null;
  correlationId: string | null;
}

export interface EventRecord {
  id: string;
  type: string;
  sourceAgentId: string | null;
  payload: Record<string, unknown>;
  metadata: Record<string, unknown>;
  createdAt: string;
}

export interface EventSubscription {
  id: string;
  agentId: string;
  eventType: string;
  filter: Record<string, unknown>;
  createdAt: string;
}

export interface SendMessageInput {
  senderId: string;
  recipientId?: string;
  payload: Record<string, unknown>;
  topic?: string;
  correlationId?: string;
}

export interface EventPublishInput {
  type: string;
  sourceAgentId?: string;
  payload: Record<string, unknown>;
  metadata?: Record<string, unknown>;
}

export interface EventReplayOptions {
  type?: string;
  sourceAgentId?: string;
  since?: string;
  limit?: number;
}

export interface MessageQuery {
  senderId?: string;
  recipientId?: string;
  status?: MessageStatus;
  kind?: MessageKind;
  limit?: number;
}

export interface AgentRegistrationInput {
  id: string;
  name?: string;
  metadata?: Record<string, unknown>;
}

export interface SessionConnectInput {
  agentId: string;
  metadata?: Record<string, unknown>;
}


path: tests/communication-hub.test.ts
content: import { afterEach, describe, expect, test } from "vitest";
import { CommunicationHub } from "../src/CommunicationHub.js";

function createHub(): CommunicationHub {
  const hub = new CommunicationHub();
  hub.sessions.registerAgent({ id: "agent-alpha", name: "Alpha" });
  hub.sessions.registerAgent({ id: "agent-beta", name: "Beta" });
  hub.sessions.registerAgent({ id: "agent-gamma", name: "Gamma" });
  return hub;
}

let activeHub: CommunicationHub | undefined;

afterEach(() => {
  activeHub?.close();
  activeHub = undefined;
});

describe("CommunicationHub", () => {
  test("queues direct messages for offline agents and drains them on reconnect", () => {
    activeHub = createHub();
    activeHub.sessions.connectAgent({ agentId: "agent-alpha" });

    const message = activeHub.sendDirectMessage({
      senderId: "agent-alpha",
      recipientId: "agent-beta",
      payload: { text: "ping" },
    });

    expect(message.status).toBe("pending");
    expect(activeHub.getPendingMessages("agent-beta")).toHaveLength(1);

    activeHub.sessions.connectAgent({ agentId: "agent-beta" });
    const delivered = activeHub.drainOfflineQueue("agent-beta");

    expect(delivered).toHaveLength(1);
    expect(delivered[0]?.status).toBe("delivered");
  });

  test("acknowledges delivered messages", () => {
    activeHub = createHub();
    activeHub.sessions.connectAgent({ agentId: "agent-alpha" });
    activeHub.sessions.connectAgent({ agentId: "agent-beta" });

    const message = activeHub.sendPrivateMessage({
      senderId: "agent-alpha",
      recipientId: "agent-beta",
      payload: { text: "secret" },
      topic: "private.sync",
    });

    expect(message.status).toBe("delivered");

    const acknowledged = activeHub.acknowledgeMessage(message.id);
    expect(acknowledged.status).toBe("acknowledged");
    expect(acknowledged.acknowledgedAt).not.toBeNull();
  });

  test("broadcasts to all other registered agents", () => {
    activeHub = createHub();
    activeHub.sessions.connectAgent({ agentId: "agent-alpha" });
    activeHub.sessions.connectAgent({ agentId: "agent-beta" });

    const messages = activeHub.broadcastMessage({
      senderId: "agent-alpha",
      payload: { announcement: true },
      topic: "system.broadcast",
    });

    expect(messages).toHaveLength(2);
    expect(messages.map((message) => message.recipientId).sort()).toEqual(["agent-beta", "agent-gamma"]);
  });

  test("tracks subscriptions and replays matching events", () => {
    activeHub = createHub();
    activeHub.events.subscribe("agent-beta", "task.updated", { priority: "high" });
    activeHub.events.subscribe("agent-gamma", "task.updated", { priority: "low" });

    const published = activeHub.events.publish({
      type: "task.updated",
      sourceAgentId: "agent-alpha",
      payload: { id: "task-1", priority: "high" },
      metadata: { origin: "planner" },
    });

    expect(published.recipients).toEqual(["agent-beta"]);

    const replayed = activeHub.events.replay({ type: "task.updated", limit: 10 });
    expect(replayed).toHaveLength(1);
    expect(replayed[0]?.payload).toMatchObject({ id: "task-1" });
  });

  test("stores session history", () => {
    activeHub = createHub();
    activeHub.sessions.connectAgent({ agentId: "agent-beta", metadata: { region: "cn" } });
    activeHub.sessions.disconnectAgent("agent-beta");

    const history = activeHub.sessions.getSessionHistory("agent-beta");
    expect(history).toHaveLength(1);
    expect(history[0]?.status).toBe("disconnected");
    expect(history[0]?.metadata).toMatchObject({ region: "cn" });
  });
});


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "types": [
      "node"
    ]
  },
  "include": [
    "src/**/*.ts",
    "tests/**/*.ts",
    "examples/**/*.ts"
  ],
  "exclude": [
    "dist",
    "node_modules"
  ]
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)