agent-relay-server 0.1.0 → 0.3.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/src/config.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  // Shared runtime constants. Import from here rather than redefining.
2
2
 
3
+ import { readFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
9
+ export const VERSION: string = pkg.version;
10
+
3
11
  export const STALE_TTL_MS = 600_000; // 10min without heartbeat → offline
4
12
  export const REAP_INTERVAL_MS = 60_000; // reaper cadence
5
13
  export const DAY_MS = 86_400_000;
package/src/db.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { VERSION } from "./config.ts";
2
3
  import type {
3
4
  AgentCard,
4
5
  Message,
6
+ MessageType,
5
7
  RegisterAgentInput,
6
8
  SendMessageInput,
7
9
  PollQuery,
@@ -33,6 +35,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
33
35
  id INTEGER PRIMARY KEY AUTOINCREMENT,
34
36
  from_agent TEXT NOT NULL,
35
37
  to_target TEXT NOT NULL,
38
+ type TEXT NOT NULL DEFAULT 'message',
36
39
  channel TEXT,
37
40
  subject TEXT,
38
41
  body TEXT NOT NULL,
@@ -65,6 +68,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
65
68
  }
66
69
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
67
70
 
71
+ if (!colNames.includes("type")) {
72
+ db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
73
+ }
74
+
68
75
  // Backfill thread_id for pre-migration rows (self-threaded).
69
76
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
70
77
 
@@ -85,18 +92,22 @@ export function initDb(path: string = "agent-relay.db"): Database {
85
92
  if (!agentColNames.includes("label")) {
86
93
  db.run("ALTER TABLE agents ADD COLUMN label TEXT");
87
94
  }
95
+ if (!agentColNames.includes("ready")) {
96
+ db.run("ALTER TABLE agents ADD COLUMN ready INTEGER NOT NULL DEFAULT 0");
97
+ }
88
98
  db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
89
99
 
90
- // Built-in "user" agent represents the human operator driving the
91
- // dashboard. Registered unconditionally so dashboard sends (from=user)
92
- // pass the sendMessage validation. The reaper exempts this id so it
93
- // never flips to offline.
100
+ // Built-in agentsregistered unconditionally so sends from these ids
101
+ // pass the sendMessage validation. The reaper exempts these by checking
102
+ // meta.builtin (or by id for "user").
94
103
  const now = Date.now();
95
- db.prepare(`
96
- INSERT INTO agents (id, name, tags, machine, rig, capabilities, status, meta, last_seen, created_at)
97
- VALUES ('user', 'User', '["human"]', NULL, NULL, '[]', 'online', '{"builtin":true}', ?, ?)
98
- ON CONFLICT(id) DO UPDATE SET status = 'online', last_seen = excluded.last_seen
99
- `).run(now, now);
104
+ const builtinStmt = db.prepare(`
105
+ INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
106
+ VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
107
+ ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
108
+ `);
109
+ builtinStmt.run("user", "User", '["human"]', now, now);
110
+ builtinStmt.run("system", "System", '["system"]', now, now);
100
111
 
101
112
  // One-shot migration: backfill message_reads from legacy read_by JSON
102
113
  // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
@@ -131,6 +142,7 @@ function rowToAgent(row: any): AgentCard {
131
142
  machine: row.machine ?? undefined,
132
143
  rig: row.rig ?? undefined,
133
144
  capabilities: parseJson(row.capabilities, []),
145
+ ready: row.ready === 1,
134
146
  status: row.status,
135
147
  meta: parseJson(row.meta, {}),
136
148
  lastSeen: row.last_seen,
@@ -143,6 +155,7 @@ function rowToMessage(row: any): Message {
143
155
  id: row.id,
144
156
  from: row.from_agent,
145
157
  to: row.to_target,
158
+ type: (row.type ?? "message") as MessageType,
146
159
  channel: row.channel ?? undefined,
147
160
  subject: row.subject ?? undefined,
148
161
  body: row.body,
@@ -168,9 +181,10 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
168
181
  // Preserve the existing label across re-registrations unless the caller
169
182
  // explicitly sends one (including null to clear).
170
183
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
184
+ const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
171
185
  const stmt = db.prepare(`
172
- INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, status, meta, last_seen, created_at)
173
- VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $status, $meta, $now, $now)
186
+ INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
187
+ VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $meta, $now, $now)
174
188
  ON CONFLICT(id) DO UPDATE SET
175
189
  name = $name,
176
190
  label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
@@ -178,6 +192,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
178
192
  machine = coalesce($machine, agents.machine),
179
193
  rig = coalesce($rig, agents.rig),
180
194
  capabilities = $capabilities,
195
+ ready = CASE WHEN $readyProvided = 1 THEN $ready ELSE agents.ready END,
181
196
  status = $status,
182
197
  meta = $meta,
183
198
  last_seen = $now
@@ -192,6 +207,8 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
192
207
  $machine: input.machine ?? null,
193
208
  $rig: input.rig ?? null,
194
209
  $capabilities: JSON.stringify(input.capabilities ?? []),
210
+ $ready: input.ready ? 1 : 0,
211
+ $readyProvided: readyProvided ? 1 : 0,
195
212
  $status: input.status ?? "idle",
196
213
  $meta: JSON.stringify(input.meta ?? {}),
197
214
  $now: now,
@@ -238,11 +255,20 @@ export function listAgents(filter?: {
238
255
  }
239
256
 
240
257
  export function setStatus(id: string, status: AgentCard["status"]): boolean {
258
+ const now = Date.now();
259
+ const ready = status === "offline" ? 0 : undefined;
260
+ const sql = ready === 0
261
+ ? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
262
+ : "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
263
+ return db.prepare(sql).run(status, now, id).changes > 0;
264
+ }
265
+
266
+ export function markReady(id: string, ready: boolean): boolean {
241
267
  const now = Date.now();
242
268
  return (
243
269
  db
244
- .prepare("UPDATE agents SET status = ?, last_seen = ? WHERE id = ?")
245
- .run(status, now, id).changes > 0
270
+ .prepare("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
271
+ .run(ready ? 1 : 0, now, id).changes > 0
246
272
  );
247
273
  }
248
274
 
@@ -253,13 +279,14 @@ export function heartbeat(id: string): boolean {
253
279
  return result.changes > 0;
254
280
  }
255
281
 
256
- export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): number {
282
+ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
257
283
  const cutoff = Date.now() - ttlMs;
258
- return db
284
+ const rows = db
259
285
  .prepare(
260
- "UPDATE agents SET status = 'offline' WHERE status != 'offline' AND last_seen < ? AND id != 'user'"
286
+ "UPDATE agents SET status = 'offline', ready = 0 WHERE status != 'offline' AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
261
287
  )
262
- .run(cutoff).changes;
288
+ .all(cutoff) as any[];
289
+ return rows.map((r: any) => r.id);
263
290
  }
264
291
 
265
292
  export function findAgentsByCapability(capability: string, onlineOnly = true): AgentCard[] {
@@ -303,8 +330,8 @@ export function sendMessage(input: SendMessageInput): Message {
303
330
  }
304
331
 
305
332
  const insert = db.prepare(`
306
- INSERT INTO messages (from_agent, to_target, channel, subject, body, thread_id, reply_to, claimable, meta, created_at)
307
- VALUES ($from, $to, $channel, $subject, $body, $threadId, $replyTo, $claimable, $meta, $now)
333
+ INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, meta, created_at)
334
+ VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $meta, $now)
308
335
  `);
309
336
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
310
337
 
@@ -312,6 +339,7 @@ export function sendMessage(input: SendMessageInput): Message {
312
339
  const result = insert.run({
313
340
  $from: input.from,
314
341
  $to: input.to,
342
+ $type: input.type ?? "message",
315
343
  $channel: input.channel ?? null,
316
344
  $subject: input.subject ?? null,
317
345
  $body: input.body,
@@ -471,6 +499,7 @@ export function pruneOldMessages(maxAgeMs: number): number {
471
499
  }
472
500
 
473
501
  export function getStats(): {
502
+ version: string;
474
503
  agents: number;
475
504
  online: number;
476
505
  messages: number;
@@ -495,5 +524,5 @@ export function getStats(): {
495
524
  .get(Date.now() - DAY_MS) as any
496
525
  ).c;
497
526
 
498
- return { agents, online, messages, messagesLast24h };
527
+ return { version: VERSION, agents, online, messages, messagesLast24h };
499
528
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { initDb, reapStaleAgents, pruneOldMessages } from "./db";
3
3
  import { matchRoute } from "./routes";
4
+ import { emitAgentStatus } from "./sse";
4
5
  import { resolve, sep } from "path";
5
6
  import { REAP_INTERVAL_MS, STALE_TTL_MS, MAX_BODY_BYTES, DAY_MS } from "./config";
6
7
 
@@ -14,7 +15,10 @@ initDb(DB_PATH);
14
15
 
15
16
  setInterval(() => {
16
17
  const reaped = reapStaleAgents(STALE_TTL_MS);
17
- if (reaped > 0) console.log(`reaped ${reaped} stale agent(s)`);
18
+ if (reaped.length > 0) {
19
+ console.log(`reaped ${reaped.length} stale agent(s)`);
20
+ for (const id of reaped) emitAgentStatus(id);
21
+ }
18
22
  }, REAP_INTERVAL_MS);
19
23
 
20
24
  // Daily message prune
package/src/routes.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  findAgentsByCapability,
6
6
  setStatus,
7
7
  setLabel,
8
+ markReady,
8
9
  heartbeat,
9
10
  deleteAgent,
10
11
  sendMessage,
@@ -21,6 +22,14 @@ import {
21
22
  } from "./db";
22
23
  import type { RegisterAgentInput, SendMessageInput, PollQuery } from "./types";
23
24
  import { MAX_BODY_BYTES } from "./config";
25
+ import {
26
+ createSSEStream,
27
+ emitNewMessage,
28
+ emitAgentStatus,
29
+ emitAgentRemoved,
30
+ emitMessageClaimed,
31
+ emitMessageDeleted,
32
+ } from "./sse";
24
33
 
25
34
  type Handler = (
26
35
  req: Request,
@@ -110,7 +119,9 @@ const postAgent: Handler = async (req) => {
110
119
  if (!parsed.ok) return error(parsed.error, parsed.status);
111
120
  const body = parsed.body;
112
121
  if (!body?.id || !body?.name) return error("id and name required");
113
- return json(upsertAgent(body), 201);
122
+ const agent = upsertAgent(body);
123
+ emitAgentStatus(agent.id);
124
+ return json(agent, 201);
114
125
  };
115
126
 
116
127
  const getAgents: Handler = (req) => {
@@ -141,9 +152,9 @@ const patchAgentStatus: Handler = async (req, params) => {
141
152
  if (!body?.status) return error("status required");
142
153
  const valid = ["online", "idle", "busy", "offline"];
143
154
  if (!valid.includes(body.status)) return error(`status must be one of: ${valid.join(", ")}`);
144
- return setStatus(params.id!, body.status as any)
145
- ? json({ ok: true })
146
- : error("agent not found", 404);
155
+ if (!setStatus(params.id!, body.status as any)) return error("agent not found", 404);
156
+ emitAgentStatus(params.id!);
157
+ return json({ ok: true });
147
158
  };
148
159
 
149
160
  const postHeartbeat: Handler = (_req, params) => {
@@ -155,20 +166,35 @@ const patchAgentLabel: Handler = async (req, params) => {
155
166
  if (!parsed.ok) return error(parsed.error, parsed.status);
156
167
  const body = parsed.body;
157
168
  if (body === null || !("label" in body)) return error("label field required (string or null)");
158
- return setLabel(params.id!, body.label)
159
- ? json({ ok: true })
160
- : error("agent not found", 404);
169
+ if (!setLabel(params.id!, body.label)) return error("agent not found", 404);
170
+ emitAgentStatus(params.id!);
171
+ return json({ ok: true });
172
+ };
173
+
174
+ const patchAgentReady: Handler = async (req, params) => {
175
+ const parsed = await parseBody<{ ready: boolean }>(req);
176
+ if (!parsed.ok) return error(parsed.error, parsed.status);
177
+ const body = parsed.body;
178
+ if (body === null || typeof body.ready !== "boolean") return error("ready field required (boolean)");
179
+ if (!markReady(params.id!, body.ready)) return error("agent not found", 404);
180
+ emitAgentStatus(params.id!);
181
+ return json({ ok: true });
161
182
  };
162
183
 
163
184
  const deleteAgentById: Handler = (_req, params) => {
164
185
  const result = deleteAgent(params.id!);
165
- if (result.ok) return json({ ok: true });
166
- const status = result.error === "agent not found" ? 404 : 400;
167
- return error(result.error!, status);
186
+ if (!result.ok) {
187
+ const status = result.error === "agent not found" ? 404 : 400;
188
+ return error(result.error!, status);
189
+ }
190
+ emitAgentRemoved(params.id!);
191
+ return json({ ok: true });
168
192
  };
169
193
 
170
194
  // --- Message routes ---
171
195
 
196
+ const VALID_MSG_TYPES = ["message", "system"];
197
+
172
198
  const postMessage: Handler = async (req) => {
173
199
  const parsed = await parseBody<SendMessageInput>(req);
174
200
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -176,8 +202,34 @@ const postMessage: Handler = async (req) => {
176
202
  if (!body?.from || !body?.to || !body?.body) {
177
203
  return error("from, to, and body required");
178
204
  }
205
+ if (body.type && !VALID_MSG_TYPES.includes(body.type)) {
206
+ return error(`type must be one of: ${VALID_MSG_TYPES.join(", ")}`);
207
+ }
208
+ try {
209
+ const msg = sendMessage(body);
210
+ emitNewMessage(msg);
211
+ return json(msg, 201);
212
+ } catch (e) {
213
+ if (e instanceof ValidationError) return error(e.message, 400);
214
+ throw e;
215
+ }
216
+ };
217
+
218
+ const postSystemBroadcast: Handler = async (req) => {
219
+ const parsed = await parseBody<{ body: string; subject?: string }>(req);
220
+ if (!parsed.ok) return error(parsed.error, parsed.status);
221
+ const body = parsed.body;
222
+ if (!body?.body) return error("body required");
179
223
  try {
180
- return json(sendMessage(body), 201);
224
+ const msg = sendMessage({
225
+ from: "system",
226
+ to: "broadcast",
227
+ type: "system",
228
+ subject: body.subject ?? undefined,
229
+ body: body.body,
230
+ });
231
+ emitNewMessage(msg);
232
+ return json(msg, 201);
181
233
  } catch (e) {
182
234
  if (e instanceof ValidationError) return error(e.message, 400);
183
235
  throw e;
@@ -199,10 +251,10 @@ const getMessages: Handler = (req) => {
199
251
  const since = sinceRaw ?? undefined;
200
252
 
201
253
  const sinceIdRaw = parseQueryInt(url.searchParams.get("sinceId"), {
202
- min: 1,
254
+ min: 0,
203
255
  max: Number.MAX_SAFE_INTEGER,
204
256
  });
205
- if (Number.isNaN(sinceIdRaw)) return error("sinceId must be a positive integer");
257
+ if (Number.isNaN(sinceIdRaw)) return error("sinceId must be a non-negative integer");
206
258
  const sinceId = sinceIdRaw ?? undefined;
207
259
 
208
260
  const channel = url.searchParams.get("channel") ?? undefined;
@@ -245,7 +297,10 @@ const postClaimMessage: Handler = async (req, params) => {
245
297
  const body = parsed.body;
246
298
  if (!body?.agentId) return error("agentId required");
247
299
  const result = claimMessage(id, body.agentId);
248
- if (result.ok) return json({ ok: true });
300
+ if (result.ok) {
301
+ emitMessageClaimed(id, body.agentId);
302
+ return json({ ok: true });
303
+ }
249
304
  const status =
250
305
  result.error === "message not found" ? 404 :
251
306
  result.error === "claiming agent not found" ? 400 :
@@ -267,11 +322,21 @@ const patchMessage: Handler = async (req, params) => {
267
322
  const deleteMessageById: Handler = (_req, params) => {
268
323
  const id = parseId(params.id);
269
324
  if (id === null) return error("invalid message id");
270
- return deleteMessage(id) ? json({ ok: true }) : error("message not found", 404);
325
+ if (!deleteMessage(id)) return error("message not found", 404);
326
+ emitMessageDeleted(id);
327
+ return json({ ok: true });
271
328
  };
272
329
 
273
330
  const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
274
331
 
332
+ // --- SSE ---
333
+
334
+ const getEvents: Handler = (req) => {
335
+ const url = new URL(req.url);
336
+ const agentId = url.searchParams.get("for") || null;
337
+ return createSSEStream(agentId);
338
+ };
339
+
275
340
  // --- Stats ---
276
341
 
277
342
  const getStatsRoute: Handler = () => json(getStats());
@@ -304,10 +369,12 @@ const routes: Route[] = [
304
369
  route("GET", "/api/agents/find", findAgents),
305
370
  route("GET", "/api/agents/:id", getAgentById),
306
371
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
372
+ route("PATCH", "/api/agents/:id/ready", patchAgentReady),
307
373
  route("PATCH", "/api/agents/:id/label", patchAgentLabel),
308
374
  route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
309
375
  route("DELETE", "/api/agents/:id", deleteAgentById),
310
376
 
377
+ route("POST", "/api/system/broadcast", postSystemBroadcast),
311
378
  route("POST", "/api/messages", postMessage),
312
379
  route("GET", "/api/messages", getMessages),
313
380
  route("GET", "/api/messages/cursor", getCursorRoute),
@@ -317,6 +384,7 @@ const routes: Route[] = [
317
384
  route("PATCH", "/api/messages/:id", patchMessage),
318
385
  route("DELETE", "/api/messages/:id", deleteMessageById),
319
386
 
387
+ route("GET", "/api/events", getEvents),
320
388
  route("GET", "/api/stats", getStatsRoute),
321
389
  ];
322
390
 
package/src/sse.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { getAgent } from "./db";
2
+ import type { Message } from "./types";
3
+
4
+ interface Connection {
5
+ id: string;
6
+ agentId: string | null;
7
+ controller: ReadableStreamDefaultController<Uint8Array>;
8
+ keepalive: Timer;
9
+ }
10
+
11
+ const connections = new Map<string, Connection>();
12
+ const encoder = new TextEncoder();
13
+
14
+ export function createSSEStream(agentId: string | null): Response {
15
+ let connId: string;
16
+
17
+ const stream = new ReadableStream<Uint8Array>({
18
+ start(controller) {
19
+ connId = crypto.randomUUID();
20
+ const keepalive = setInterval(() => {
21
+ try { controller.enqueue(encoder.encode(": keepalive\n\n")); }
22
+ catch { removeConnection(connId); }
23
+ }, 30_000);
24
+
25
+ connections.set(connId, { id: connId, agentId, controller, keepalive });
26
+
27
+ controller.enqueue(encoder.encode(
28
+ `event: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
29
+ ));
30
+ },
31
+ cancel() {
32
+ removeConnection(connId);
33
+ },
34
+ });
35
+
36
+ return new Response(stream, {
37
+ headers: {
38
+ "Content-Type": "text/event-stream",
39
+ "Cache-Control": "no-cache",
40
+ "Connection": "keep-alive",
41
+ "Access-Control-Allow-Origin": "*",
42
+ },
43
+ });
44
+ }
45
+
46
+ function removeConnection(id: string) {
47
+ const conn = connections.get(id);
48
+ if (conn) {
49
+ clearInterval(conn.keepalive);
50
+ connections.delete(id);
51
+ }
52
+ }
53
+
54
+ function send(conn: Connection, event: string, data: unknown) {
55
+ try {
56
+ conn.controller.enqueue(encoder.encode(
57
+ `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
58
+ ));
59
+ } catch {
60
+ removeConnection(conn.id);
61
+ }
62
+ }
63
+
64
+ function messageMatchesAgent(msg: Message, agentId: string): boolean {
65
+ const agent = getAgent(agentId);
66
+ if (!agent) return false;
67
+ if (msg.to === agentId || msg.from === agentId) return true;
68
+ if (msg.to === "broadcast") return true;
69
+ if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
70
+ if (msg.to.startsWith("cap:") && agent.capabilities.includes(msg.to.slice(4))) return true;
71
+ if (msg.to.startsWith("label:") && agent.label === msg.to.slice(6)) return true;
72
+ return false;
73
+ }
74
+
75
+ export function emitNewMessage(msg: Message) {
76
+ for (const conn of connections.values()) {
77
+ if (!conn.agentId) {
78
+ send(conn, "message.new", msg);
79
+ continue;
80
+ }
81
+ if (!messageMatchesAgent(msg, conn.agentId)) continue;
82
+ if (msg.claimable && msg.claimedBy && msg.claimedBy !== conn.agentId) continue;
83
+ send(conn, "message.new", msg);
84
+ }
85
+ }
86
+
87
+ export function emitAgentStatus(agentId: string) {
88
+ const agent = getAgent(agentId);
89
+ const data = agent ?? { id: agentId, status: "offline" };
90
+ for (const conn of connections.values()) {
91
+ send(conn, "agent.status", data);
92
+ }
93
+ }
94
+
95
+ export function emitAgentRemoved(agentId: string) {
96
+ for (const conn of connections.values()) {
97
+ send(conn, "agent.removed", { id: agentId });
98
+ }
99
+ }
100
+
101
+ export function emitMessageClaimed(messageId: number, claimedBy: string) {
102
+ for (const conn of connections.values()) {
103
+ send(conn, "message.claimed", { messageId, claimedBy });
104
+ }
105
+ }
106
+
107
+ export function emitMessageDeleted(messageId: number) {
108
+ for (const conn of connections.values()) {
109
+ send(conn, "message.deleted", { messageId });
110
+ }
111
+ }
112
+
113
+ export function getConnectionCount(): number {
114
+ return connections.size;
115
+ }
package/src/types.ts CHANGED
@@ -6,16 +6,20 @@ export interface AgentCard {
6
6
  machine?: string;
7
7
  rig?: string;
8
8
  capabilities: string[];
9
+ ready: boolean;
9
10
  status: "online" | "idle" | "busy" | "offline";
10
11
  meta?: Record<string, unknown>;
11
12
  lastSeen: number;
12
13
  createdAt: number;
13
14
  }
14
15
 
16
+ export type MessageType = "message" | "system";
17
+
15
18
  export interface Message {
16
19
  id: number;
17
20
  from: string;
18
21
  to: string; // agent-id | "tag:<name>" | "broadcast" | "cap:<name>"
22
+ type: MessageType;
19
23
  channel?: string;
20
24
  subject?: string;
21
25
  body: string;
@@ -32,6 +36,7 @@ export interface Message {
32
36
  export interface SendMessageInput {
33
37
  from: string;
34
38
  to: string;
39
+ type?: MessageType;
35
40
  channel?: string;
36
41
  subject?: string;
37
42
  body: string;
@@ -57,6 +62,7 @@ export interface RegisterAgentInput {
57
62
  machine?: string;
58
63
  rig?: string;
59
64
  capabilities?: string[];
65
+ ready?: boolean;
60
66
  status?: AgentCard["status"];
61
67
  meta?: Record<string, unknown>;
62
68
  }