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/README.md +177 -91
- package/bin/agent-relay-codex.ts +547 -0
- package/codex/README.md +80 -0
- package/codex/app-client.ts +239 -0
- package/codex/hooks/session-start.ts +114 -0
- package/codex/install-codex.ps1 +47 -0
- package/codex/install-codex.sh +75 -0
- package/codex/live-sidecar.ts +606 -0
- package/codex/plugin/.codex-plugin/plugin.json +25 -0
- package/codex/plugin/skills/agent-relay/SKILL.md +28 -0
- package/codex/relay.ts +116 -0
- package/codex/start-live.sh +64 -0
- package/package.json +14 -3
- package/public/index.html +1078 -446
- package/src/config.ts +8 -0
- package/src/db.ts +49 -20
- package/src/index.ts +5 -1
- package/src/routes.ts +83 -15
- package/src/sse.ts +115 -0
- package/src/types.ts +6 -0
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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// never flips to offline.
|
|
100
|
+
// Built-in agents — registered 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 (
|
|
98
|
-
ON CONFLICT(id) DO UPDATE SET status = 'online', last_seen = excluded.last_seen
|
|
99
|
-
`)
|
|
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
|
|
245
|
-
.run(
|
|
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):
|
|
282
|
+
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
257
283
|
const cutoff = Date.now() - ttlMs;
|
|
258
|
-
|
|
284
|
+
const rows = db
|
|
259
285
|
.prepare(
|
|
260
|
-
"UPDATE agents SET status = 'offline' WHERE status != 'offline' AND last_seen < ? AND id
|
|
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
|
-
.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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)
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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:
|
|
254
|
+
min: 0,
|
|
203
255
|
max: Number.MAX_SAFE_INTEGER,
|
|
204
256
|
});
|
|
205
|
-
if (Number.isNaN(sinceIdRaw)) return error("sinceId must be a
|
|
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)
|
|
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
|
-
|
|
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
|
}
|