@johpaz/hive-core 1.0.7 → 1.0.10

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.
Files changed (53) hide show
  1. package/package.json +10 -9
  2. package/src/agent/ethics.ts +70 -68
  3. package/src/agent/index.ts +48 -17
  4. package/src/agent/providers/index.ts +11 -5
  5. package/src/agent/soul.ts +19 -15
  6. package/src/agent/user.ts +19 -15
  7. package/src/agent/workspace.ts +6 -6
  8. package/src/agents/index.ts +4 -0
  9. package/src/agents/inter-agent-bus.test.ts +264 -0
  10. package/src/agents/inter-agent-bus.ts +279 -0
  11. package/src/agents/registry.test.ts +275 -0
  12. package/src/agents/registry.ts +273 -0
  13. package/src/agents/router.test.ts +229 -0
  14. package/src/agents/router.ts +251 -0
  15. package/src/agents/team-coordinator.test.ts +401 -0
  16. package/src/agents/team-coordinator.ts +480 -0
  17. package/src/canvas/canvas-manager.test.ts +159 -0
  18. package/src/canvas/canvas-manager.ts +219 -0
  19. package/src/canvas/canvas-tools.ts +189 -0
  20. package/src/canvas/index.ts +2 -0
  21. package/src/channels/whatsapp.ts +12 -12
  22. package/src/config/loader.ts +12 -9
  23. package/src/events/event-bus.test.ts +98 -0
  24. package/src/events/event-bus.ts +171 -0
  25. package/src/gateway/server.ts +131 -35
  26. package/src/index.ts +9 -1
  27. package/src/multi-agent/manager.ts +12 -12
  28. package/src/plugins/api.ts +129 -0
  29. package/src/plugins/index.ts +2 -0
  30. package/src/plugins/loader.test.ts +285 -0
  31. package/src/plugins/loader.ts +363 -0
  32. package/src/resilience/circuit-breaker.test.ts +129 -0
  33. package/src/resilience/circuit-breaker.ts +223 -0
  34. package/src/security/google-chat.test.ts +219 -0
  35. package/src/security/google-chat.ts +269 -0
  36. package/src/security/index.ts +5 -0
  37. package/src/security/pairing.test.ts +302 -0
  38. package/src/security/pairing.ts +250 -0
  39. package/src/security/rate-limit.test.ts +239 -0
  40. package/src/security/rate-limit.ts +270 -0
  41. package/src/security/signal.test.ts +92 -0
  42. package/src/security/signal.ts +321 -0
  43. package/src/state/store.test.ts +190 -0
  44. package/src/state/store.ts +310 -0
  45. package/src/storage/sqlite.ts +3 -3
  46. package/src/tools/cron.ts +42 -2
  47. package/src/tools/dynamic-registry.test.ts +226 -0
  48. package/src/tools/dynamic-registry.ts +258 -0
  49. package/src/tools/fs.test.ts +127 -0
  50. package/src/tools/fs.ts +364 -0
  51. package/src/tools/index.ts +1 -0
  52. package/src/tools/read.ts +23 -19
  53. package/src/utils/logger.ts +112 -33
@@ -0,0 +1,219 @@
1
+ import { EventEmitter } from "events";
2
+ import { logger } from "../utils/logger.ts";
3
+ import { eventBus } from "../events/event-bus.ts";
4
+
5
+ export interface WebSocketLike {
6
+ readyState: number;
7
+ send(data: string): void;
8
+ on(event: "message", callback: (data: unknown) => void): void;
9
+ on(event: "close", callback: () => void): void;
10
+ }
11
+
12
+ export const WebSocketState = {
13
+ CONNECTING: 0,
14
+ OPEN: 1,
15
+ CLOSING: 2,
16
+ CLOSED: 3,
17
+ };
18
+
19
+ export interface CanvasComponent {
20
+ id: string;
21
+ type: "button" | "form" | "chart" | "table" | "markdown" | "text" | "image";
22
+ props: Record<string, unknown>;
23
+ }
24
+
25
+ export interface CanvasMessage {
26
+ type: "canvas:render" | "canvas:update" | "canvas:clear" | "canvas:interact";
27
+ payload: {
28
+ sessionId: string;
29
+ componentId?: string;
30
+ component?: CanvasComponent;
31
+ action?: string;
32
+ data?: unknown;
33
+ };
34
+ }
35
+
36
+ export interface InteractionEvent {
37
+ sessionId: string;
38
+ componentId: string;
39
+ action: string;
40
+ data: unknown;
41
+ }
42
+
43
+ interface PendingInteraction {
44
+ resolve: (data: unknown) => void;
45
+ reject: (error: Error) => void;
46
+ timeoutId: ReturnType<typeof setTimeout>;
47
+ }
48
+
49
+ export class CanvasManager extends EventEmitter {
50
+ private sessions: Map<string, WebSocketLike> = new Map();
51
+ private pendingInteractions: Map<string, PendingInteraction> = new Map();
52
+ private log = logger.child("canvas");
53
+
54
+ registerSession(sessionId: string, ws: WebSocketLike): void {
55
+ this.sessions.set(sessionId, ws);
56
+ this.log.info(`Canvas session registered: ${sessionId}`);
57
+
58
+ ws.on("message", (data: unknown) => {
59
+ try {
60
+ const msg = JSON.parse(data as string) as CanvasMessage;
61
+ this.handleMessage(sessionId, msg);
62
+ } catch (error) {
63
+ this.log.error(`Invalid canvas message: ${(error as Error).message}`);
64
+ }
65
+ });
66
+
67
+ ws.on("close", () => {
68
+ this.sessions.delete(sessionId);
69
+ this.cleanupPendingInteractions(sessionId);
70
+ this.log.info(`Canvas session disconnected: ${sessionId}`);
71
+ });
72
+
73
+ eventBus.emit("tool:completed" as any, {
74
+ toolName: "canvas:session:register",
75
+ result: { sessionId },
76
+ duration: 0,
77
+ success: true,
78
+ });
79
+ }
80
+
81
+ private handleMessage(sessionId: string, msg: CanvasMessage): void {
82
+ if (msg.type === "canvas:interact") {
83
+ const { componentId, action, data } = msg.payload;
84
+
85
+ if (componentId) {
86
+ this.resolveInteraction(sessionId, componentId, data);
87
+ }
88
+
89
+ this.emit("interaction", {
90
+ sessionId,
91
+ componentId,
92
+ action,
93
+ data,
94
+ } as InteractionEvent);
95
+ }
96
+ }
97
+
98
+ private resolveInteraction(sessionId: string, componentId: string, data: unknown): void {
99
+ const key = `${sessionId}:${componentId}`;
100
+ const pending = this.pendingInteractions.get(key);
101
+
102
+ if (pending) {
103
+ clearTimeout(pending.timeoutId);
104
+ this.pendingInteractions.delete(key);
105
+ pending.resolve(data);
106
+ }
107
+ }
108
+
109
+ async render(sessionId: string, component: CanvasComponent): Promise<void> {
110
+ const ws = this.sessions.get(sessionId);
111
+
112
+ if (!ws || ws.readyState !== WebSocketState.OPEN) {
113
+ throw new Error(`Session not connected: ${sessionId}`);
114
+ }
115
+
116
+ const message: CanvasMessage = {
117
+ type: "canvas:render",
118
+ payload: { sessionId, component },
119
+ };
120
+
121
+ ws.send(JSON.stringify(message));
122
+ this.log.debug(`Rendered component ${component.id} to session ${sessionId}`);
123
+ }
124
+
125
+ async update(sessionId: string, component: CanvasComponent): Promise<void> {
126
+ const ws = this.sessions.get(sessionId);
127
+
128
+ if (!ws || ws.readyState !== WebSocketState.OPEN) {
129
+ throw new Error(`Session not connected: ${sessionId}`);
130
+ }
131
+
132
+ const message: CanvasMessage = {
133
+ type: "canvas:update",
134
+ payload: { sessionId, component },
135
+ };
136
+
137
+ ws.send(JSON.stringify(message));
138
+ this.log.debug(`Updated component ${component.id} in session ${sessionId}`);
139
+ }
140
+
141
+ async clear(sessionId: string): Promise<void> {
142
+ const ws = this.sessions.get(sessionId);
143
+
144
+ if (!ws || ws.readyState !== WebSocketState.OPEN) {
145
+ throw new Error(`Session not connected: ${sessionId}`);
146
+ }
147
+
148
+ const message: CanvasMessage = {
149
+ type: "canvas:clear",
150
+ payload: { sessionId },
151
+ };
152
+
153
+ ws.send(JSON.stringify(message));
154
+ this.log.debug(`Cleared canvas for session ${sessionId}`);
155
+ }
156
+
157
+ async waitForInteraction(
158
+ sessionId: string,
159
+ componentId: string,
160
+ timeout = 300000
161
+ ): Promise<unknown> {
162
+ const key = `${sessionId}:${componentId}`;
163
+
164
+ return new Promise((resolve, reject) => {
165
+ const timeoutId = setTimeout(() => {
166
+ this.pendingInteractions.delete(key);
167
+ reject(new Error(`Interaction timeout for ${componentId}`));
168
+ }, timeout);
169
+
170
+ this.pendingInteractions.set(key, { resolve, reject, timeoutId });
171
+ });
172
+ }
173
+
174
+ handleInteraction(sessionId: string, componentId: string, data: unknown): void {
175
+ this.resolveInteraction(sessionId, componentId, data);
176
+ }
177
+
178
+ isSessionConnected(sessionId: string): boolean {
179
+ const ws = this.sessions.get(sessionId);
180
+ return ws !== undefined && ws.readyState === WebSocketState.OPEN;
181
+ }
182
+
183
+ getConnectedSessions(): string[] {
184
+ return Array.from(this.sessions.entries())
185
+ .filter(([_, ws]) => ws.readyState === WebSocketState.OPEN)
186
+ .map(([id]) => id);
187
+ }
188
+
189
+ getStats(): { totalSessions: number; activeSessions: number; pendingInteractions: number } {
190
+ return {
191
+ totalSessions: this.sessions.size,
192
+ activeSessions: this.getConnectedSessions().length,
193
+ pendingInteractions: this.pendingInteractions.size,
194
+ };
195
+ }
196
+
197
+ private cleanupPendingInteractions(sessionId: string): void {
198
+ for (const [key, pending] of this.pendingInteractions) {
199
+ if (key.startsWith(`${sessionId}:`)) {
200
+ clearTimeout(pending.timeoutId);
201
+ pending.reject(new Error(`Session disconnected: ${sessionId}`));
202
+ this.pendingInteractions.delete(key);
203
+ }
204
+ }
205
+ }
206
+
207
+ clearAll(): void {
208
+ for (const pending of this.pendingInteractions.values()) {
209
+ clearTimeout(pending.timeoutId);
210
+ pending.reject(new Error("Canvas manager cleared"));
211
+ }
212
+
213
+ this.pendingInteractions.clear();
214
+ this.sessions.clear();
215
+ this.log.info("Canvas manager cleared");
216
+ }
217
+ }
218
+
219
+ export const canvasManager = new CanvasManager();
@@ -0,0 +1,189 @@
1
+ import type { Tool } from "../tools/registry.ts";
2
+ import type { Config } from "../config/loader.ts";
3
+ import { canvasManager } from "./canvas-manager.ts";
4
+ import { logger } from "../utils/logger.ts";
5
+
6
+ export function createCanvasRenderTool(_config: Config): Tool {
7
+ const log = logger.child("canvas-render");
8
+
9
+ return {
10
+ name: "canvas_render",
11
+ description: "Render a component on the user's canvas",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ sessionId: {
16
+ type: "string",
17
+ description: "Session ID to render to",
18
+ },
19
+ component: {
20
+ type: "object",
21
+ properties: {
22
+ id: {
23
+ type: "string",
24
+ description: "Unique component ID",
25
+ },
26
+ type: {
27
+ type: "string",
28
+ enum: ["button", "form", "chart", "table", "markdown", "text", "image"],
29
+ description: "Component type",
30
+ },
31
+ props: {
32
+ type: "object",
33
+ description: "Component properties",
34
+ },
35
+ },
36
+ required: ["id", "type", "props"],
37
+ },
38
+ },
39
+ required: ["sessionId", "component"],
40
+ },
41
+ execute: async (params: Record<string, unknown>) => {
42
+ const sessionId = params.sessionId as string;
43
+ const component = params.component as {
44
+ id: string;
45
+ type: "button" | "form" | "chart" | "table" | "markdown" | "text" | "image";
46
+ props: Record<string, unknown>;
47
+ };
48
+
49
+ log.debug(`Rendering component ${component.id} to session ${sessionId}`);
50
+
51
+ await canvasManager.render(sessionId, {
52
+ id: component.id,
53
+ type: component.type,
54
+ props: component.props,
55
+ });
56
+
57
+ return {
58
+ success: true,
59
+ componentId: component.id,
60
+ sessionId,
61
+ };
62
+ },
63
+ };
64
+ }
65
+
66
+ export function createCanvasAskTool(_config: Config): Tool {
67
+ const log = logger.child("canvas-ask");
68
+
69
+ return {
70
+ name: "canvas_ask",
71
+ description: "Display a form and wait for user response",
72
+ parameters: {
73
+ type: "object",
74
+ properties: {
75
+ sessionId: {
76
+ type: "string",
77
+ description: "Session ID",
78
+ },
79
+ title: {
80
+ type: "string",
81
+ description: "Form title",
82
+ },
83
+ fields: {
84
+ type: "array",
85
+ items: {
86
+ type: "object",
87
+ properties: {
88
+ name: { type: "string" },
89
+ label: { type: "string" },
90
+ type: { type: "string", enum: ["text", "email", "textarea", "select"] },
91
+ required: { type: "boolean" },
92
+ options: {
93
+ type: "array",
94
+ items: {
95
+ type: "object",
96
+ properties: {
97
+ label: { type: "string" },
98
+ value: { type: "string" },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ required: ["name", "label", "type"],
104
+ },
105
+ description: "Form fields",
106
+ },
107
+ timeout: {
108
+ type: "number",
109
+ description: "Timeout in milliseconds (default: 300000)",
110
+ },
111
+ },
112
+ required: ["sessionId", "fields"],
113
+ },
114
+ execute: async (params: Record<string, unknown>) => {
115
+ const sessionId = params.sessionId as string;
116
+ const title = (params.title as string) ?? "Form";
117
+ const fields = params.fields as Array<{
118
+ name: string;
119
+ label: string;
120
+ type: string;
121
+ required?: boolean;
122
+ options?: Array<{ label: string; value: string }>;
123
+ }>;
124
+ const timeout = (params.timeout as number) ?? 300000;
125
+
126
+ const formId = `form-${Date.now()}`;
127
+
128
+ log.debug(`Asking user via form ${formId}`);
129
+
130
+ await canvasManager.render(sessionId, {
131
+ id: formId,
132
+ type: "form",
133
+ props: { title, fields },
134
+ });
135
+
136
+ try {
137
+ const response = await canvasManager.waitForInteraction(sessionId, formId, timeout);
138
+
139
+ return {
140
+ success: true,
141
+ formId,
142
+ data: response,
143
+ };
144
+ } catch (error) {
145
+ return {
146
+ success: false,
147
+ formId,
148
+ error: (error as Error).message,
149
+ };
150
+ }
151
+ },
152
+ };
153
+ }
154
+
155
+ export function createCanvasClearTool(_config: Config): Tool {
156
+ const log = logger.child("canvas-clear");
157
+
158
+ return {
159
+ name: "canvas_clear",
160
+ description: "Clear the canvas for a session",
161
+ parameters: {
162
+ type: "object",
163
+ properties: {
164
+ sessionId: {
165
+ type: "string",
166
+ description: "Session ID to clear",
167
+ },
168
+ },
169
+ required: ["sessionId"],
170
+ },
171
+ execute: async (params: Record<string, unknown>) => {
172
+ const sessionId = params.sessionId as string;
173
+
174
+ log.debug(`Clearing canvas for session ${sessionId}`);
175
+
176
+ await canvasManager.clear(sessionId);
177
+
178
+ return { success: true, sessionId };
179
+ },
180
+ };
181
+ }
182
+
183
+ export function createCanvasTools(config: Config): Tool[] {
184
+ return [
185
+ createCanvasRenderTool(config),
186
+ createCanvasAskTool(config),
187
+ createCanvasClearTool(config),
188
+ ];
189
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./canvas-manager.ts";
2
+ export * from "./canvas-tools.ts";
@@ -6,7 +6,7 @@ import makeWASocket, {
6
6
  } from "@whiskeysockets/baileys";
7
7
  import type { ChannelConfig, IncomingMessage, OutboundMessage } from "./base.ts";
8
8
  import { BaseChannel } from "./base.ts";
9
- import * as fs from "node:fs";
9
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { logger } from "../utils/logger.ts";
12
12
 
@@ -29,7 +29,7 @@ export class WhatsAppChannel extends BaseChannel {
29
29
  name = "whatsapp";
30
30
  accountId: string;
31
31
  config: WhatsAppConfig;
32
-
32
+
33
33
  private socket: WASocket | null = null;
34
34
  private connectionState: WhatsAppConnectionState = {
35
35
  status: "disconnected",
@@ -50,11 +50,11 @@ export class WhatsAppChannel extends BaseChannel {
50
50
  private getAuthPath(agentId: string, accountId: string): string {
51
51
  const baseDir = process.env.HOME ?? "";
52
52
  const authDir = path.join(baseDir, ".hive", "agents", agentId, "whatsapp", accountId);
53
-
54
- if (!fs.existsSync(authDir)) {
55
- fs.mkdirSync(authDir, { recursive: true });
53
+
54
+ if (!existsSync(authDir)) {
55
+ mkdirSync(authDir, { recursive: true });
56
56
  }
57
-
57
+
58
58
  return authDir;
59
59
  }
60
60
 
@@ -65,7 +65,7 @@ export class WhatsAppChannel extends BaseChannel {
65
65
 
66
66
  async stop(): Promise<void> {
67
67
  this.running = false;
68
-
68
+
69
69
  if (this.reconnectTimeout) {
70
70
  clearTimeout(this.reconnectTimeout);
71
71
  this.reconnectTimeout = null;
@@ -144,8 +144,8 @@ export class WhatsAppChannel extends BaseChannel {
144
144
 
145
145
  if (statusCode === DisconnectReason.loggedOut) {
146
146
  this.log.error("WhatsApp logged out - session invalidated. Need to re-scan QR.");
147
- fs.rmSync(this.authPath, { recursive: true, force: true });
148
- fs.mkdirSync(this.authPath, { recursive: true });
147
+ rmSync(this.authPath, { recursive: true, force: true });
148
+ mkdirSync(this.authPath, { recursive: true });
149
149
  }
150
150
 
151
151
  if (shouldReconnect && this.running) {
@@ -167,7 +167,7 @@ export class WhatsAppChannel extends BaseChannel {
167
167
  console.log("\n" + "=".repeat(50));
168
168
  console.log(" WHATSAPP QR CODE - Scan with your phone");
169
169
  console.log("=".repeat(50) + "\n");
170
-
170
+
171
171
  const qrcode = require("qrcode-terminal");
172
172
  qrcode.generate(qr, { small: false }, (qrString: string) => {
173
173
  console.log(qrString);
@@ -188,7 +188,7 @@ export class WhatsAppChannel extends BaseChannel {
188
188
  messageTimestamp?: number;
189
189
  pushName?: string;
190
190
  };
191
-
191
+
192
192
  if (typedMsg.key.fromMe) continue;
193
193
 
194
194
  const from = typedMsg.key.remoteJid;
@@ -315,7 +315,7 @@ export class WhatsAppChannel extends BaseChannel {
315
315
  const jid = this.getJid(sessionId);
316
316
  try {
317
317
  await this.socket.readMessages([
318
- { key: { remoteJid: jid, id: messageId, fromMe: false } }
318
+ { remoteJid: jid, id: messageId, fromMe: false }
319
319
  ]);
320
320
  } catch {
321
321
  // Ignore read receipt errors
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import * as fs from "node:fs";
2
+ import { mkdirSync } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as yaml from "js-yaml";
5
5
 
@@ -474,7 +474,7 @@ function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial
474
474
  return result;
475
475
  }
476
476
 
477
- export function loadConfig(configPath?: string): Config {
477
+ export async function loadConfig(configPath?: string): Promise<Config> {
478
478
  const paths = configPath
479
479
  ? [configPath]
480
480
  : [
@@ -487,9 +487,10 @@ export function loadConfig(configPath?: string): Config {
487
487
  let loadedConfig: Partial<Config> = {};
488
488
 
489
489
  for (const p of paths) {
490
- if (fs.existsSync(p)) {
490
+ const file = Bun.file(p);
491
+ if (await file.exists()) {
491
492
  try {
492
- const content = fs.readFileSync(p, "utf-8");
493
+ const content = await file.text();
493
494
  const parsed = yaml.load(content);
494
495
  loadedConfig = expandEnvInObject(parsed as Partial<Config>);
495
496
  break;
@@ -509,7 +510,7 @@ export function loadConfig(configPath?: string): Config {
509
510
  return result.data;
510
511
  }
511
512
 
512
- export function saveConfig(config: Config, configPath?: string): void {
513
+ export async function saveConfig(config: Config, configPath?: string): Promise<void> {
513
514
  const paths = configPath
514
515
  ? [configPath]
515
516
  : [
@@ -519,7 +520,8 @@ export function saveConfig(config: Config, configPath?: string): void {
519
520
 
520
521
  let targetPath = paths[0];
521
522
  for (const p of paths) {
522
- if (fs.existsSync(p)) {
523
+ const file = Bun.file(p);
524
+ if (await file.exists()) {
523
525
  targetPath = p;
524
526
  break;
525
527
  }
@@ -527,15 +529,16 @@ export function saveConfig(config: Config, configPath?: string): void {
527
529
 
528
530
  if (targetPath) {
529
531
  const dir = path.dirname(targetPath);
530
- if (!fs.existsSync(dir)) {
531
- fs.mkdirSync(dir, { recursive: true });
532
+ const dirFile = Bun.file(dir);
533
+ if (!(await dirFile.exists())) {
534
+ mkdirSync(dir, { recursive: true });
532
535
  }
533
536
  const yamlStr = yaml.dump(config, {
534
537
  indent: 2,
535
538
  lineWidth: -1,
536
539
  noRefs: true,
537
540
  });
538
- fs.writeFileSync(targetPath, yamlStr, "utf-8");
541
+ await Bun.write(targetPath, yamlStr);
539
542
  }
540
543
  }
541
544
 
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { eventBus } from "../events/event-bus";
3
+
4
+ describe("TypedEventBus", () => {
5
+ beforeEach(() => {
6
+ eventBus.removeAllListeners();
7
+ });
8
+
9
+ it("should emit and receive typed events", async () => {
10
+ let receivedData: any = null;
11
+
12
+ eventBus.on("message:received", (data) => {
13
+ receivedData = data;
14
+ });
15
+
16
+ eventBus.emit("message:received", {
17
+ channel: "telegram",
18
+ userId: "123",
19
+ content: "hello",
20
+ timestamp: Date.now(),
21
+ sessionId: "session-1",
22
+ });
23
+
24
+ expect(receivedData).not.toBeNull();
25
+ expect(receivedData.channel).toBe("telegram");
26
+ expect(receivedData.content).toBe("hello");
27
+ });
28
+
29
+ it("should handle multiple listeners", () => {
30
+ const calls: string[] = [];
31
+
32
+ eventBus.on("agent:thinking", () => { calls.push("first"); });
33
+ eventBus.on("agent:thinking", () => { calls.push("second"); });
34
+
35
+ eventBus.emit("agent:thinking", {
36
+ agentId: "agent-1",
37
+ sessionId: "session-1",
38
+ stage: "planning",
39
+ });
40
+
41
+ expect(calls).toEqual(["first", "second"]);
42
+ });
43
+
44
+ it("should return unsubscribe function", () => {
45
+ const calls: string[] = [];
46
+
47
+ const unsubscribe = eventBus.on("tool:executing", () => { calls.push("called"); });
48
+
49
+ eventBus.emit("tool:executing", {
50
+ toolName: "read",
51
+ args: { path: "/test" },
52
+ sessionId: "session-1",
53
+ });
54
+
55
+ expect(calls.length).toBe(1);
56
+
57
+ unsubscribe();
58
+
59
+ eventBus.emit("tool:executing", {
60
+ toolName: "write",
61
+ args: { path: "/test2" },
62
+ sessionId: "session-1",
63
+ });
64
+
65
+ expect(calls.length).toBe(1);
66
+ });
67
+
68
+ it("should handle once listener", () => {
69
+ const calls: number[] = [];
70
+
71
+ eventBus.once("session:started", () => { calls.push(1); });
72
+
73
+ eventBus.emit("session:started", {
74
+ sessionId: "s1",
75
+ agentId: "agent-1",
76
+ channel: "telegram",
77
+ userId: "user-1",
78
+ });
79
+
80
+ eventBus.emit("session:started", {
81
+ sessionId: "s2",
82
+ agentId: "agent-1",
83
+ channel: "telegram",
84
+ userId: "user-1",
85
+ });
86
+
87
+ expect(calls.length).toBe(1);
88
+ });
89
+
90
+ it("should track listener count", () => {
91
+ eventBus.removeAllListeners("mcp:connected");
92
+
93
+ eventBus.on("mcp:connected", () => {});
94
+ eventBus.on("mcp:connected", () => {});
95
+
96
+ expect(eventBus.listenerCount("mcp:connected")).toBe(2);
97
+ });
98
+ });