agent-inbox 0.0.1 → 0.1.2

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 (126) hide show
  1. package/CLAUDE.md +113 -0
  2. package/README.md +195 -1
  3. package/dist/federation/address.d.ts +24 -0
  4. package/dist/federation/address.d.ts.map +1 -0
  5. package/dist/federation/address.js +54 -0
  6. package/dist/federation/address.js.map +1 -0
  7. package/dist/federation/connection-manager.d.ts +118 -0
  8. package/dist/federation/connection-manager.d.ts.map +1 -0
  9. package/dist/federation/connection-manager.js +369 -0
  10. package/dist/federation/connection-manager.js.map +1 -0
  11. package/dist/federation/delivery-queue.d.ts +66 -0
  12. package/dist/federation/delivery-queue.d.ts.map +1 -0
  13. package/dist/federation/delivery-queue.js +199 -0
  14. package/dist/federation/delivery-queue.js.map +1 -0
  15. package/dist/federation/index.d.ts +7 -0
  16. package/dist/federation/index.d.ts.map +1 -0
  17. package/dist/federation/index.js +6 -0
  18. package/dist/federation/index.js.map +1 -0
  19. package/dist/federation/routing-engine.d.ts +74 -0
  20. package/dist/federation/routing-engine.d.ts.map +1 -0
  21. package/dist/federation/routing-engine.js +158 -0
  22. package/dist/federation/routing-engine.js.map +1 -0
  23. package/dist/federation/trust.d.ts +39 -0
  24. package/dist/federation/trust.d.ts.map +1 -0
  25. package/dist/federation/trust.js +64 -0
  26. package/dist/federation/trust.js.map +1 -0
  27. package/dist/index.d.ts +60 -2
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +217 -18
  30. package/dist/index.js.map +1 -1
  31. package/dist/ipc/ipc-server.d.ts +21 -0
  32. package/dist/ipc/ipc-server.d.ts.map +1 -0
  33. package/dist/ipc/ipc-server.js +173 -0
  34. package/dist/ipc/ipc-server.js.map +1 -0
  35. package/dist/jsonrpc/mail-server.d.ts +45 -0
  36. package/dist/jsonrpc/mail-server.d.ts.map +1 -0
  37. package/dist/jsonrpc/mail-server.js +284 -0
  38. package/dist/jsonrpc/mail-server.js.map +1 -0
  39. package/dist/map/map-client.d.ts +91 -0
  40. package/dist/map/map-client.d.ts.map +1 -0
  41. package/dist/map/map-client.js +202 -0
  42. package/dist/map/map-client.js.map +1 -0
  43. package/dist/mcp/mcp-server.d.ts +23 -0
  44. package/dist/mcp/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp/mcp-server.js +226 -0
  46. package/dist/mcp/mcp-server.js.map +1 -0
  47. package/dist/push/notifier.d.ts +49 -0
  48. package/dist/push/notifier.d.ts.map +1 -0
  49. package/dist/push/notifier.js +150 -0
  50. package/dist/push/notifier.js.map +1 -0
  51. package/dist/registry/warm-registry.d.ts +63 -0
  52. package/dist/registry/warm-registry.d.ts.map +1 -0
  53. package/dist/registry/warm-registry.js +173 -0
  54. package/dist/registry/warm-registry.js.map +1 -0
  55. package/dist/router/message-router.d.ts +44 -0
  56. package/dist/router/message-router.d.ts.map +1 -0
  57. package/dist/router/message-router.js +137 -0
  58. package/dist/router/message-router.js.map +1 -0
  59. package/dist/storage/interface.d.ts +31 -0
  60. package/dist/storage/interface.d.ts.map +1 -0
  61. package/dist/storage/interface.js +2 -0
  62. package/dist/storage/interface.js.map +1 -0
  63. package/dist/storage/memory.d.ts +28 -0
  64. package/dist/storage/memory.d.ts.map +1 -0
  65. package/dist/storage/memory.js +118 -0
  66. package/dist/storage/memory.js.map +1 -0
  67. package/dist/storage/sqlite.d.ts +35 -0
  68. package/dist/storage/sqlite.d.ts.map +1 -0
  69. package/dist/storage/sqlite.js +445 -0
  70. package/dist/storage/sqlite.js.map +1 -0
  71. package/dist/traceability/traceability.d.ts +29 -0
  72. package/dist/traceability/traceability.d.ts.map +1 -0
  73. package/dist/traceability/traceability.js +150 -0
  74. package/dist/traceability/traceability.js.map +1 -0
  75. package/dist/types.d.ts +261 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +3 -0
  78. package/dist/types.js.map +1 -0
  79. package/docs/DESIGN.md +1156 -0
  80. package/docs/PLAN.md +545 -0
  81. package/hooks/inbox-hook.mjs +119 -0
  82. package/hooks/register-hook.mjs +69 -0
  83. package/package.json +33 -25
  84. package/rules/agent-inbox.md +78 -0
  85. package/src/federation/address.ts +61 -0
  86. package/src/federation/connection-manager.ts +458 -0
  87. package/src/federation/delivery-queue.ts +222 -0
  88. package/src/federation/index.ts +6 -0
  89. package/src/federation/routing-engine.ts +188 -0
  90. package/src/federation/trust.ts +71 -0
  91. package/src/index.ts +299 -0
  92. package/src/ipc/ipc-server.ts +207 -0
  93. package/src/jsonrpc/mail-server.ts +356 -0
  94. package/src/map/map-client.ts +260 -0
  95. package/src/mcp/mcp-server.ts +272 -0
  96. package/src/push/notifier.ts +192 -0
  97. package/src/registry/warm-registry.ts +210 -0
  98. package/src/router/message-router.ts +175 -0
  99. package/src/storage/interface.ts +48 -0
  100. package/src/storage/memory.ts +145 -0
  101. package/src/storage/sqlite.ts +645 -0
  102. package/src/traceability/traceability.ts +183 -0
  103. package/src/types.ts +297 -0
  104. package/test/federation/address.test.ts +101 -0
  105. package/test/federation/connection-manager.test.ts +546 -0
  106. package/test/federation/delivery-queue.test.ts +159 -0
  107. package/test/federation/integration.test.ts +823 -0
  108. package/test/federation/routing-engine.test.ts +117 -0
  109. package/test/federation/sdk-integration.test.ts +748 -0
  110. package/test/federation/trust.test.ts +89 -0
  111. package/test/ipc-jsonrpc.test.ts +113 -0
  112. package/test/ipc-server.test.ts +197 -0
  113. package/test/mail-server.test.ts +208 -0
  114. package/test/map-client.test.ts +408 -0
  115. package/test/message-router.test.ts +184 -0
  116. package/test/push-notifier.test.ts +139 -0
  117. package/test/registry/warm-registry.test.ts +171 -0
  118. package/test/sqlite-storage.test.ts +243 -0
  119. package/test/storage.test.ts +196 -0
  120. package/test/traceability.test.ts +123 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +10 -0
  123. package/vitest.config.ts +8 -0
  124. package/dist/index.d.mts +0 -2
  125. package/dist/index.mjs +0 -1
  126. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,207 @@
1
+ import * as net from "node:net";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type {
5
+ IpcCommand,
6
+ IpcResponse,
7
+ Agent,
8
+ } from "../types.js";
9
+ import type { Storage } from "../storage/interface.js";
10
+ import type { MessageRouter } from "../router/message-router.js";
11
+ import type { MailJsonRpcServer } from "../jsonrpc/mail-server.js";
12
+
13
+ export class IpcServer {
14
+ private server: net.Server | null = null;
15
+
16
+ constructor(
17
+ private socketPath: string,
18
+ private router: MessageRouter,
19
+ private storage: Storage,
20
+ private jsonRpc?: MailJsonRpcServer
21
+ ) {}
22
+
23
+ async start(): Promise<void> {
24
+ // Clean up stale socket file
25
+ try {
26
+ fs.unlinkSync(this.socketPath);
27
+ } catch {
28
+ // Doesn't exist, fine
29
+ }
30
+
31
+ // Ensure directory exists
32
+ const dir = path.dirname(this.socketPath);
33
+ fs.mkdirSync(dir, { recursive: true });
34
+
35
+ return new Promise((resolve, reject) => {
36
+ this.server = net.createServer((conn) => this.handleConnection(conn));
37
+ this.server.on("error", reject);
38
+ this.server.listen(this.socketPath, () => resolve());
39
+ });
40
+ }
41
+
42
+ async stop(): Promise<void> {
43
+ return new Promise((resolve) => {
44
+ if (!this.server) return resolve();
45
+ this.server.close(() => {
46
+ try {
47
+ fs.unlinkSync(this.socketPath);
48
+ } catch {
49
+ // Already gone
50
+ }
51
+ resolve();
52
+ });
53
+ });
54
+ }
55
+
56
+ private handleConnection(conn: net.Socket): void {
57
+ let buffer = "";
58
+
59
+ conn.on("data", (data) => {
60
+ buffer += data.toString();
61
+ // Process complete lines (NDJSON)
62
+ let newlineIdx: number;
63
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
64
+ const line = buffer.slice(0, newlineIdx).trim();
65
+ buffer = buffer.slice(newlineIdx + 1);
66
+ if (!line) continue;
67
+
68
+ this.processLine(line)
69
+ .then((response) => {
70
+ conn.write(JSON.stringify(response) + "\n");
71
+ })
72
+ .catch((err) => {
73
+ conn.write(
74
+ JSON.stringify({ ok: false, error: String(err) }) + "\n"
75
+ );
76
+ });
77
+ }
78
+ });
79
+
80
+ conn.on("error", () => {
81
+ // Client disconnected, ignore
82
+ });
83
+ }
84
+
85
+ private async processLine(line: string): Promise<IpcResponse | object> {
86
+ let parsed: Record<string, unknown>;
87
+ try {
88
+ parsed = JSON.parse(line) as Record<string, unknown>;
89
+ } catch {
90
+ return { ok: false, error: "Invalid JSON" };
91
+ }
92
+
93
+ // Detect JSON-RPC requests (have "jsonrpc" field) vs IPC commands (have "action" field)
94
+ if (parsed.jsonrpc === "2.0" && typeof parsed.method === "string") {
95
+ if (this.jsonRpc) {
96
+ return this.jsonRpc.handleRequest(
97
+ parsed as { jsonrpc: "2.0"; id?: string | number | null; method: string; params?: Record<string, unknown> }
98
+ );
99
+ }
100
+ return { jsonrpc: "2.0", id: parsed.id ?? null, error: { code: -32603, message: "JSON-RPC not configured" } };
101
+ }
102
+
103
+ return this.handleCommand(parsed as unknown as IpcCommand);
104
+ }
105
+
106
+ async handleCommand(command: IpcCommand): Promise<IpcResponse> {
107
+ switch (command.action) {
108
+ case "ping":
109
+ return { ok: true, pid: process.pid };
110
+
111
+ case "send":
112
+ return this.handleSend(command);
113
+
114
+ case "notify":
115
+ return this.handleNotify(command);
116
+
117
+ case "check_inbox":
118
+ return this.handleCheckInbox(command);
119
+
120
+ case "emit":
121
+ // For Phase 1, emit is acknowledged but not forwarded to MAP
122
+ return { ok: true };
123
+
124
+ default:
125
+ return { ok: false, error: `Unknown action: ${(command as { action: string }).action}` };
126
+ }
127
+ }
128
+
129
+ private async handleSend(
130
+ command: Extract<IpcCommand, { action: "send" }>
131
+ ): Promise<IpcResponse> {
132
+ try {
133
+ const message = await this.router.routeMessage({
134
+ from: command.from,
135
+ to: command.to,
136
+ payload: command.payload,
137
+ scope: command.scope,
138
+ threadTag: command.threadTag,
139
+ inReplyTo: command.inReplyTo,
140
+ importance: command.importance,
141
+ metadata: command.meta,
142
+ });
143
+ return { ok: true, messageId: message.id };
144
+ } catch (err) {
145
+ return { ok: false, error: String(err) };
146
+ }
147
+ }
148
+
149
+ private handleCheckInbox(
150
+ command: Extract<IpcCommand, { action: "check_inbox" }>
151
+ ): IpcResponse {
152
+ const agentId = command.agentId ?? command.scope ?? "default";
153
+ const messages = this.storage.getInbox(agentId, {
154
+ unreadOnly: command.unreadOnly,
155
+ });
156
+
157
+ // Mark messages as read
158
+ if (command.clear) {
159
+ const now = new Date().toISOString();
160
+ for (const msg of messages) {
161
+ for (const r of msg.recipients) {
162
+ if (r.agent_id === agentId && !r.read_at) {
163
+ r.read_at = now;
164
+ }
165
+ }
166
+ this.storage.putMessage(msg);
167
+ }
168
+ }
169
+
170
+ return { ok: true, messages };
171
+ }
172
+
173
+ private handleNotify(
174
+ command: Extract<IpcCommand, { action: "notify" }>
175
+ ): IpcResponse {
176
+ const event = command.event;
177
+
178
+ if (event.type === "agent.spawn" && event.agent) {
179
+ const now = new Date().toISOString();
180
+ const agent: Agent = {
181
+ agent_id: event.agent.agentId,
182
+ display_name: event.agent.name,
183
+ scope: event.agent.scopes?.[0] ?? "default",
184
+ status: "active",
185
+ metadata: event.agent.metadata ?? {},
186
+ registered_at: now,
187
+ last_active_at: now,
188
+ };
189
+ this.storage.putAgent(agent);
190
+ return { ok: true };
191
+ }
192
+
193
+ if (event.type === "agent.done") {
194
+ const agentId = event.agentId ?? event.agent?.agentId;
195
+ if (agentId) {
196
+ const agent = this.storage.getAgent(agentId);
197
+ if (agent) {
198
+ agent.status = "offline";
199
+ this.storage.putAgent(agent);
200
+ }
201
+ }
202
+ return { ok: true };
203
+ }
204
+
205
+ return { ok: true };
206
+ }
207
+ }
@@ -0,0 +1,356 @@
1
+ import * as net from "node:net";
2
+ import * as http from "node:http";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { EventEmitter } from "node:events";
6
+ import type { Storage } from "../storage/interface.js";
7
+ import type { MessageRouter } from "../router/message-router.js";
8
+ import type { Conversation, Turn, Thread } from "../types.js";
9
+ import { ulid } from "ulid";
10
+
11
+ /**
12
+ * MAP mail/* JSON-RPC 2.0 endpoint.
13
+ * Serves over both UNIX socket (IPC inline) and HTTP (dashboard access).
14
+ */
15
+
16
+ interface JsonRpcRequest {
17
+ jsonrpc: "2.0";
18
+ id?: string | number | null;
19
+ method: string;
20
+ params?: Record<string, unknown>;
21
+ }
22
+
23
+ interface JsonRpcResponse {
24
+ jsonrpc: "2.0";
25
+ id: string | number | null;
26
+ result?: unknown;
27
+ error?: { code: number; message: string; data?: unknown };
28
+ }
29
+
30
+ type MethodHandler = (
31
+ params: Record<string, unknown>
32
+ ) => Promise<unknown> | unknown;
33
+
34
+ export class MailJsonRpcServer {
35
+ private methods = new Map<string, MethodHandler>();
36
+ private httpServer: http.Server | null = null;
37
+ private subscribers = new Set<http.ServerResponse>();
38
+
39
+ constructor(
40
+ private storage: Storage,
41
+ private router: MessageRouter,
42
+ private events: EventEmitter
43
+ ) {
44
+ this.registerMethods();
45
+ this.setupEventForwarding();
46
+ }
47
+
48
+ private registerMethods(): void {
49
+ // mail/create — create a conversation
50
+ this.methods.set("mail/create", (params) => {
51
+ const now = new Date().toISOString();
52
+ const conv: Conversation = {
53
+ id: (params.id as string) ?? `conv-${ulid()}`,
54
+ scope: (params.scope as string) ?? "default",
55
+ subject: params.subject as string | undefined,
56
+ status: "active",
57
+ participants: [],
58
+ metadata: (params.metadata as Record<string, unknown>) ?? {},
59
+ created_at: now,
60
+ updated_at: now,
61
+ };
62
+ this.storage.putConversation(conv);
63
+ return conv;
64
+ });
65
+
66
+ // mail/get — get conversation details
67
+ this.methods.set("mail/get", (params) => {
68
+ const conv = this.storage.getConversation(params.id as string);
69
+ if (!conv) throw rpcError(-32001, "Conversation not found");
70
+ const turns = this.storage.getTurns(conv.id);
71
+ const threads = this.storage.getThreadsByConversation(conv.id);
72
+ return { conversation: conv, turns, threads };
73
+ });
74
+
75
+ // mail/list — list conversations
76
+ this.methods.set("mail/list", (params) => {
77
+ const conversations = this.storage.listConversations(
78
+ params.scope as string | undefined
79
+ );
80
+ return { conversations };
81
+ });
82
+
83
+ // mail/close — close a conversation
84
+ this.methods.set("mail/close", (params) => {
85
+ const conv = this.storage.getConversation(params.id as string);
86
+ if (!conv) throw rpcError(-32001, "Conversation not found");
87
+ conv.status = "completed";
88
+ conv.updated_at = new Date().toISOString();
89
+ this.storage.putConversation(conv);
90
+ return { ok: true };
91
+ });
92
+
93
+ // mail/join — add self as participant
94
+ this.methods.set("mail/join", (params) => {
95
+ const conv = this.storage.getConversation(
96
+ params.conversationId as string
97
+ );
98
+ if (!conv) throw rpcError(-32001, "Conversation not found");
99
+ const agentId = params.agentId as string;
100
+ if (!conv.participants.some((p) => p.agent_id === agentId)) {
101
+ conv.participants.push({
102
+ agent_id: agentId,
103
+ joined_at: new Date().toISOString(),
104
+ });
105
+ conv.updated_at = new Date().toISOString();
106
+ this.storage.putConversation(conv);
107
+ }
108
+ return { ok: true };
109
+ });
110
+
111
+ // mail/leave — remove self from conversation
112
+ this.methods.set("mail/leave", (params) => {
113
+ const conv = this.storage.getConversation(
114
+ params.conversationId as string
115
+ );
116
+ if (!conv) throw rpcError(-32001, "Conversation not found");
117
+ const agentId = params.agentId as string;
118
+ conv.participants = conv.participants.filter(
119
+ (p) => p.agent_id !== agentId
120
+ );
121
+ conv.updated_at = new Date().toISOString();
122
+ this.storage.putConversation(conv);
123
+ return { ok: true };
124
+ });
125
+
126
+ // mail/invite — add agent to conversation
127
+ this.methods.set("mail/invite", (params) => {
128
+ const conv = this.storage.getConversation(
129
+ params.conversationId as string
130
+ );
131
+ if (!conv) throw rpcError(-32001, "Conversation not found");
132
+ const agentId = params.agentId as string;
133
+ if (!conv.participants.some((p) => p.agent_id === agentId)) {
134
+ conv.participants.push({
135
+ agent_id: agentId,
136
+ role: params.role as string | undefined,
137
+ joined_at: new Date().toISOString(),
138
+ });
139
+ conv.updated_at = new Date().toISOString();
140
+ this.storage.putConversation(conv);
141
+ }
142
+ return { ok: true };
143
+ });
144
+
145
+ // mail/turn — add a turn to a conversation
146
+ this.methods.set("mail/turn", async (params) => {
147
+ const conv = this.storage.getConversation(
148
+ params.conversationId as string
149
+ );
150
+ if (!conv) throw rpcError(-32001, "Conversation not found");
151
+
152
+ const turn: Turn = {
153
+ id: `turn-${ulid()}`,
154
+ conversation_id: conv.id,
155
+ participant_id: params.participantId as string,
156
+ source_message_id: params.sourceMessageId as string | undefined,
157
+ content_type: (params.contentType as string) ?? "text",
158
+ content: params.content as Turn["content"],
159
+ thread_id: params.threadId as string | undefined,
160
+ in_reply_to: params.inReplyTo as string | undefined,
161
+ created_at: new Date().toISOString(),
162
+ };
163
+
164
+ this.storage.addTurn(turn);
165
+ conv.updated_at = turn.created_at;
166
+ this.storage.putConversation(conv);
167
+ return turn;
168
+ });
169
+
170
+ // mail/turns/list — list turns in a conversation
171
+ this.methods.set("mail/turns/list", (params) => {
172
+ const turns = this.storage.getTurns(params.conversationId as string);
173
+ return { turns };
174
+ });
175
+
176
+ // mail/thread/create — create a thread
177
+ this.methods.set("mail/thread/create", (params) => {
178
+ const thread: Thread = {
179
+ id: `thread-${ulid()}`,
180
+ conversation_id: params.conversationId as string,
181
+ root_turn_id: params.rootTurnId as string,
182
+ parent_thread_id: params.parentThreadId as string | undefined,
183
+ subject: params.subject as string | undefined,
184
+ created_at: new Date().toISOString(),
185
+ };
186
+ this.storage.putThread(thread);
187
+ return thread;
188
+ });
189
+
190
+ // mail/thread/list — list threads in a conversation
191
+ this.methods.set("mail/thread/list", (params) => {
192
+ const threads = this.storage.getThreadsByConversation(
193
+ params.conversationId as string
194
+ );
195
+ return { threads };
196
+ });
197
+
198
+ // mail/replay — replay conversation history (all turns, ordered)
199
+ this.methods.set("mail/replay", (params) => {
200
+ const conv = this.storage.getConversation(params.id as string);
201
+ if (!conv) throw rpcError(-32001, "Conversation not found");
202
+ const turns = this.storage.getTurns(conv.id);
203
+ return {
204
+ conversation: conv,
205
+ turns,
206
+ threads: this.storage.getThreadsByConversation(conv.id),
207
+ };
208
+ });
209
+ }
210
+
211
+ /** Process a JSON-RPC request (used by both IPC and HTTP transports) */
212
+ async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> {
213
+ if (request.jsonrpc !== "2.0") {
214
+ return {
215
+ jsonrpc: "2.0",
216
+ id: request.id ?? null,
217
+ error: { code: -32600, message: "Invalid JSON-RPC version" },
218
+ };
219
+ }
220
+
221
+ const handler = this.methods.get(request.method);
222
+ if (!handler) {
223
+ return {
224
+ jsonrpc: "2.0",
225
+ id: request.id ?? null,
226
+ error: { code: -32601, message: `Method not found: ${request.method}` },
227
+ };
228
+ }
229
+
230
+ try {
231
+ const result = await handler(request.params ?? {});
232
+ return { jsonrpc: "2.0", id: request.id ?? null, result };
233
+ } catch (err) {
234
+ if (isRpcError(err)) {
235
+ return {
236
+ jsonrpc: "2.0",
237
+ id: request.id ?? null,
238
+ error: { code: err.code, message: err.message },
239
+ };
240
+ }
241
+ return {
242
+ jsonrpc: "2.0",
243
+ id: request.id ?? null,
244
+ error: { code: -32603, message: String(err) },
245
+ };
246
+ }
247
+ }
248
+
249
+ /** Start HTTP server for dashboard/external access */
250
+ async startHttp(port: number): Promise<void> {
251
+ return new Promise((resolve) => {
252
+ this.httpServer = http.createServer((req, res) => {
253
+ // SSE endpoint for map/subscribe
254
+ if (req.url === "/subscribe" && req.method === "GET") {
255
+ this.handleSseSubscribe(res);
256
+ return;
257
+ }
258
+
259
+ // JSON-RPC endpoint
260
+ if (req.method !== "POST") {
261
+ res.writeHead(405, { "Content-Type": "application/json" });
262
+ res.end(
263
+ JSON.stringify({
264
+ jsonrpc: "2.0",
265
+ id: null,
266
+ error: { code: -32600, message: "Method not allowed" },
267
+ })
268
+ );
269
+ return;
270
+ }
271
+
272
+ let body = "";
273
+ req.on("data", (chunk) => {
274
+ body += chunk;
275
+ });
276
+ req.on("end", async () => {
277
+ try {
278
+ const request = JSON.parse(body) as JsonRpcRequest;
279
+ const response = await this.handleRequest(request);
280
+ res.writeHead(200, { "Content-Type": "application/json" });
281
+ res.end(JSON.stringify(response));
282
+ } catch {
283
+ res.writeHead(400, { "Content-Type": "application/json" });
284
+ res.end(
285
+ JSON.stringify({
286
+ jsonrpc: "2.0",
287
+ id: null,
288
+ error: { code: -32700, message: "Parse error" },
289
+ })
290
+ );
291
+ }
292
+ });
293
+ });
294
+
295
+ this.httpServer.listen(port, () => resolve());
296
+ });
297
+ }
298
+
299
+ async stopHttp(): Promise<void> {
300
+ // Close all SSE connections
301
+ for (const res of this.subscribers) {
302
+ res.end();
303
+ }
304
+ this.subscribers.clear();
305
+
306
+ return new Promise((resolve) => {
307
+ if (!this.httpServer) return resolve();
308
+ this.httpServer.close(() => resolve());
309
+ });
310
+ }
311
+
312
+ /** SSE endpoint: stream events to subscribers */
313
+ private handleSseSubscribe(res: http.ServerResponse): void {
314
+ res.writeHead(200, {
315
+ "Content-Type": "text/event-stream",
316
+ "Cache-Control": "no-cache",
317
+ Connection: "keep-alive",
318
+ });
319
+ res.write(":\n\n"); // SSE comment to establish connection
320
+
321
+ this.subscribers.add(res);
322
+ res.on("close", () => {
323
+ this.subscribers.delete(res);
324
+ });
325
+ }
326
+
327
+ /** Forward internal events to SSE subscribers */
328
+ private setupEventForwarding(): void {
329
+ this.events.on("message.created", (msg) => {
330
+ this.broadcast("message.created", msg);
331
+ });
332
+ }
333
+
334
+ private broadcast(eventType: string, data: unknown): void {
335
+ const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
336
+ for (const res of this.subscribers) {
337
+ res.write(payload);
338
+ }
339
+ }
340
+ }
341
+
342
+ // --- Error helpers ---
343
+
344
+ interface RpcError extends Error {
345
+ code: number;
346
+ }
347
+
348
+ function rpcError(code: number, message: string): RpcError {
349
+ const err = new Error(message) as RpcError;
350
+ err.code = code;
351
+ return err;
352
+ }
353
+
354
+ function isRpcError(err: unknown): err is RpcError {
355
+ return err instanceof Error && typeof (err as RpcError).code === "number";
356
+ }