agent-relay-server 0.4.22 → 0.4.23

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/db.ts CHANGED
@@ -21,6 +21,9 @@ import type {
21
21
  TaskSeverity,
22
22
  TaskStatus,
23
23
  IntegrationEventInput,
24
+ InboxDraft,
25
+ InboxState,
26
+ InboxThreadState,
24
27
  TaskStatusInput,
25
28
  } from "./types";
26
29
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
@@ -144,6 +147,27 @@ export function initDb(path: string = "agent-relay.db"): Database {
144
147
  CREATE INDEX IF NOT EXISTS idx_pairs_requester ON pairs(requester_id);
145
148
  CREATE INDEX IF NOT EXISTS idx_pairs_target ON pairs(target_id);
146
149
  CREATE INDEX IF NOT EXISTS idx_pairs_status ON pairs(status);
150
+
151
+ CREATE TABLE IF NOT EXISTS inbox_thread_state (
152
+ operator_id TEXT NOT NULL,
153
+ peer_id TEXT NOT NULL,
154
+ read_cursor_message_id INTEGER,
155
+ archived_at_message_id INTEGER,
156
+ updated_at INTEGER NOT NULL,
157
+ PRIMARY KEY (operator_id, peer_id)
158
+ );
159
+ CREATE INDEX IF NOT EXISTS idx_inbox_thread_operator ON inbox_thread_state(operator_id);
160
+
161
+ CREATE TABLE IF NOT EXISTS inbox_drafts (
162
+ operator_id TEXT NOT NULL,
163
+ peer_id TEXT NOT NULL,
164
+ body TEXT NOT NULL,
165
+ subject TEXT,
166
+ channel TEXT,
167
+ updated_at INTEGER NOT NULL,
168
+ PRIMARY KEY (operator_id, peer_id)
169
+ );
170
+ CREATE INDEX IF NOT EXISTS idx_inbox_drafts_operator ON inbox_drafts(operator_id);
147
171
  `);
148
172
 
149
173
  // Migrations
@@ -257,6 +281,44 @@ function parseStringArray(raw: string): string[] {
257
281
  return parsed.filter((value): value is string => typeof value === "string");
258
282
  }
259
283
 
284
+ function normalizeTags(tags: string[] | undefined): string[] {
285
+ return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
286
+ }
287
+
288
+ function stringValue(value: unknown): string | undefined {
289
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
290
+ }
291
+
292
+ function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
293
+ const meta = input.meta ?? {};
294
+ const values = [
295
+ input.id,
296
+ input.name,
297
+ input.rig,
298
+ ...(input.tags ?? []),
299
+ stringValue(meta.provider),
300
+ stringValue(meta.client),
301
+ stringValue(meta.runtime),
302
+ stringValue(meta.agentType),
303
+ ].filter((value): value is string => typeof value === "string");
304
+
305
+ if (values.some((value) => value.toLowerCase().includes("codex"))) return "codex";
306
+ if (values.some((value) => value.toLowerCase().includes("claude"))) return "claude";
307
+
308
+ // Older Claude hooks did not always send an explicit provider tag, but did
309
+ // send Claude-style approval metadata. Codex also sends approvalMode, so this
310
+ // fallback only runs after Codex signals have been ruled out.
311
+ if (Object.prototype.hasOwnProperty.call(meta, "approvalMode")) return "claude";
312
+ return undefined;
313
+ }
314
+
315
+ function tagsWithProvider(input: RegisterAgentInput): string[] {
316
+ const tags = normalizeTags(input.tags);
317
+ const provider = inferProviderTag(input);
318
+ if (!provider || tags.includes(provider)) return tags;
319
+ return [provider, ...tags];
320
+ }
321
+
260
322
  function rowToAgent(row: any): AgentCard {
261
323
  return {
262
324
  id: row.id,
@@ -355,6 +417,27 @@ function rowToPair(row: any): PairSession {
355
417
  };
356
418
  }
357
419
 
420
+ function rowToInboxThreadState(row: any): InboxThreadState {
421
+ return {
422
+ operatorId: row.operator_id,
423
+ peerId: row.peer_id,
424
+ readCursorMessageId: row.read_cursor_message_id ?? undefined,
425
+ archivedAtMessageId: row.archived_at_message_id ?? undefined,
426
+ updatedAt: row.updated_at,
427
+ };
428
+ }
429
+
430
+ function rowToInboxDraft(row: any): InboxDraft {
431
+ return {
432
+ operatorId: row.operator_id,
433
+ peerId: row.peer_id,
434
+ body: row.body,
435
+ subject: row.subject ?? undefined,
436
+ channel: row.channel ?? undefined,
437
+ updatedAt: row.updated_at,
438
+ };
439
+ }
440
+
358
441
  const MSG_SELECT = `SELECT m.*, (
359
442
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
360
443
  ) AS read_by_agents FROM messages m`;
@@ -363,6 +446,7 @@ const MSG_SELECT = `SELECT m.*, (
363
446
 
364
447
  export function upsertAgent(input: RegisterAgentInput): AgentCard {
365
448
  const now = Date.now();
449
+ const tags = tagsWithProvider(input);
366
450
  // Preserve the existing label across re-registrations unless the caller
367
451
  // explicitly sends one (including null to clear).
368
452
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
@@ -394,7 +478,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
394
478
  $name: input.name,
395
479
  $label: input.label ?? null,
396
480
  $labelProvided: labelProvided ? 1 : 0,
397
- $tags: JSON.stringify(input.tags ?? []),
481
+ $tags: JSON.stringify(tags),
398
482
  $machine: input.machine ?? null,
399
483
  $rig: input.rig ?? null,
400
484
  $capabilities: JSON.stringify(input.capabilities ?? []),
@@ -1423,6 +1507,75 @@ export function markRead(messageId: number, agentId: string): boolean {
1423
1507
  return true;
1424
1508
  }
1425
1509
 
1510
+ export function getInboxState(operatorId: string): InboxState {
1511
+ const threads = (db.prepare(
1512
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? ORDER BY updated_at DESC",
1513
+ ).all(operatorId) as any[]).map(rowToInboxThreadState);
1514
+ const drafts = (db.prepare(
1515
+ "SELECT * FROM inbox_drafts WHERE operator_id = ? ORDER BY updated_at DESC",
1516
+ ).all(operatorId) as any[]).map(rowToInboxDraft);
1517
+ return { operatorId, threads, drafts };
1518
+ }
1519
+
1520
+ export function setInboxThreadState(input: {
1521
+ operatorId: string;
1522
+ peerId: string;
1523
+ readCursorMessageId?: number | null;
1524
+ archivedAtMessageId?: number | null;
1525
+ }): InboxThreadState {
1526
+ const now = Date.now();
1527
+ const current = db.prepare(
1528
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
1529
+ ).get(input.operatorId, input.peerId) as any | undefined;
1530
+
1531
+ const readCursorMessageId = Object.prototype.hasOwnProperty.call(input, "readCursorMessageId")
1532
+ ? input.readCursorMessageId ?? null
1533
+ : current?.read_cursor_message_id ?? null;
1534
+ const archivedAtMessageId = Object.prototype.hasOwnProperty.call(input, "archivedAtMessageId")
1535
+ ? input.archivedAtMessageId ?? null
1536
+ : current?.archived_at_message_id ?? null;
1537
+
1538
+ db.prepare(`
1539
+ INSERT INTO inbox_thread_state (operator_id, peer_id, read_cursor_message_id, archived_at_message_id, updated_at)
1540
+ VALUES (?, ?, ?, ?, ?)
1541
+ ON CONFLICT(operator_id, peer_id) DO UPDATE SET
1542
+ read_cursor_message_id = excluded.read_cursor_message_id,
1543
+ archived_at_message_id = excluded.archived_at_message_id,
1544
+ updated_at = excluded.updated_at
1545
+ `).run(input.operatorId, input.peerId, readCursorMessageId, archivedAtMessageId, now);
1546
+
1547
+ return rowToInboxThreadState(db.prepare(
1548
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
1549
+ ).get(input.operatorId, input.peerId));
1550
+ }
1551
+
1552
+ export function setInboxDraft(input: {
1553
+ operatorId: string;
1554
+ peerId: string;
1555
+ body: string;
1556
+ subject?: string | null;
1557
+ channel?: string | null;
1558
+ }): InboxDraft {
1559
+ const now = Date.now();
1560
+ db.prepare(`
1561
+ INSERT INTO inbox_drafts (operator_id, peer_id, body, subject, channel, updated_at)
1562
+ VALUES (?, ?, ?, ?, ?, ?)
1563
+ ON CONFLICT(operator_id, peer_id) DO UPDATE SET
1564
+ body = excluded.body,
1565
+ subject = excluded.subject,
1566
+ channel = excluded.channel,
1567
+ updated_at = excluded.updated_at
1568
+ `).run(input.operatorId, input.peerId, input.body, input.subject ?? null, input.channel ?? null, now);
1569
+
1570
+ return rowToInboxDraft(db.prepare(
1571
+ "SELECT * FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?",
1572
+ ).get(input.operatorId, input.peerId));
1573
+ }
1574
+
1575
+ export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
1576
+ return db.prepare("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
1577
+ }
1578
+
1426
1579
  export function deleteMessage(id: number): boolean {
1427
1580
  return db.transaction(() => {
1428
1581
  // Break reply_to references from children so the FK doesn't block delete.
package/src/routes.ts CHANGED
@@ -36,6 +36,10 @@ import {
36
36
  rejectPair,
37
37
  endPair,
38
38
  sendPairMessage,
39
+ getInboxState,
40
+ setInboxThreadState,
41
+ setInboxDraft,
42
+ deleteInboxDraft,
39
43
  createCallbackDelivery,
40
44
  finishCallbackDelivery,
41
45
  validateAgentSession,
@@ -209,6 +213,15 @@ function cleanPositiveId(value: unknown, field: string): number | undefined {
209
213
  return value;
210
214
  }
211
215
 
216
+ function cleanNullablePositiveId(value: unknown, field: string): number | null | undefined {
217
+ if (value === undefined) return undefined;
218
+ if (value === null) return null;
219
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
220
+ throw new ValidationError(`${field} must be a positive integer or null`);
221
+ }
222
+ return value;
223
+ }
224
+
212
225
  function cleanEpoch(value: unknown, field: string): number | undefined {
213
226
  if (value === undefined || value === null) return undefined;
214
227
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
@@ -370,6 +383,50 @@ function normalizePairMessageInput(body: unknown): PairMessageInput {
370
383
  };
371
384
  }
372
385
 
386
+ function cleanOperatorId(value: unknown): string {
387
+ return cleanString(value, "operatorId", { max: 200 }) || "user";
388
+ }
389
+
390
+ function normalizeInboxThreadStateInput(body: unknown): {
391
+ operatorId: string;
392
+ peerId: string;
393
+ readCursorMessageId?: number | null;
394
+ archivedAtMessageId?: number | null;
395
+ } {
396
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
397
+ const input: {
398
+ operatorId: string;
399
+ peerId: string;
400
+ readCursorMessageId?: number | null;
401
+ archivedAtMessageId?: number | null;
402
+ } = {
403
+ operatorId: cleanOperatorId(body.operatorId),
404
+ peerId: cleanString(body.peerId, "peerId", { required: true, max: 200 })!,
405
+ };
406
+ const readCursorMessageId = cleanNullablePositiveId(body.readCursorMessageId, "readCursorMessageId");
407
+ if (readCursorMessageId !== undefined) input.readCursorMessageId = readCursorMessageId;
408
+ const archivedAtMessageId = cleanNullablePositiveId(body.archivedAtMessageId, "archivedAtMessageId");
409
+ if (archivedAtMessageId !== undefined) input.archivedAtMessageId = archivedAtMessageId;
410
+ return input;
411
+ }
412
+
413
+ function normalizeInboxDraftInput(body: unknown): {
414
+ operatorId: string;
415
+ peerId: string;
416
+ body: string;
417
+ subject?: string;
418
+ channel?: string;
419
+ } {
420
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
421
+ return {
422
+ operatorId: cleanOperatorId(body.operatorId),
423
+ peerId: cleanString(body.peerId, "peerId", { required: true, max: 200 })!,
424
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
425
+ subject: cleanString(body.subject, "subject", { max: 200 }),
426
+ channel: cleanString(body.channel, "channel", { max: 120 }),
427
+ };
428
+ }
429
+
373
430
  function pairErrorStatus(code: string): number {
374
431
  if (code === "not_found") return 404;
375
432
  if (code === "busy") return 409;
@@ -730,6 +787,49 @@ const deleteMessageById: Handler = (_req, params) => {
730
787
 
731
788
  const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
732
789
 
790
+ // --- Inbox operator state ---
791
+
792
+ const getInboxStateRoute: Handler = (req) => {
793
+ const url = new URL(req.url);
794
+ const operatorId = cleanOperatorId(url.searchParams.get("operatorId") ?? undefined);
795
+ return json(getInboxState(operatorId));
796
+ };
797
+
798
+ const patchInboxThreadState: Handler = async (req) => {
799
+ const parsed = await parseBody<unknown>(req);
800
+ if (!parsed.ok) return error(parsed.error, parsed.status);
801
+ try {
802
+ return json(setInboxThreadState(normalizeInboxThreadStateInput(parsed.body)));
803
+ } catch (e) {
804
+ if (e instanceof ValidationError) return error(e.message, 400);
805
+ throw e;
806
+ }
807
+ };
808
+
809
+ const putInboxDraft: Handler = async (req) => {
810
+ const parsed = await parseBody<unknown>(req);
811
+ if (!parsed.ok) return error(parsed.error, parsed.status);
812
+ try {
813
+ return json(setInboxDraft(normalizeInboxDraftInput(parsed.body)));
814
+ } catch (e) {
815
+ if (e instanceof ValidationError) return error(e.message, 400);
816
+ throw e;
817
+ }
818
+ };
819
+
820
+ const deleteInboxDraftRoute: Handler = (req) => {
821
+ const url = new URL(req.url);
822
+ try {
823
+ const operatorId = cleanOperatorId(url.searchParams.get("operatorId") ?? undefined);
824
+ const peerId = cleanString(url.searchParams.get("peerId") ?? undefined, "peerId", { required: true, max: 200 })!;
825
+ deleteInboxDraft(operatorId, peerId);
826
+ return json({ ok: true });
827
+ } catch (e) {
828
+ if (e instanceof ValidationError) return error(e.message, 400);
829
+ throw e;
830
+ }
831
+ };
832
+
733
833
  // --- Tasks and integrations ---
734
834
 
735
835
  const postIntegrationEvent: Handler = async (req) => {
@@ -1001,6 +1101,11 @@ const routes: Route[] = [
1001
1101
  route("PATCH", "/api/messages/:id", patchMessage),
1002
1102
  route("DELETE", "/api/messages/:id", deleteMessageById),
1003
1103
 
1104
+ route("GET", "/api/inbox/state", getInboxStateRoute),
1105
+ route("PATCH", "/api/inbox/threads", patchInboxThreadState),
1106
+ route("PUT", "/api/inbox/drafts", putInboxDraft),
1107
+ route("DELETE", "/api/inbox/drafts", deleteInboxDraftRoute),
1108
+
1004
1109
  route("POST", "/api/integrations/events", postIntegrationEvent),
1005
1110
  route("GET", "/api/tasks", getTasks),
1006
1111
  route("GET", "/api/tasks/:id", getTaskById),
package/src/security.ts CHANGED
@@ -55,7 +55,7 @@ export function corsPreflight(req: Request): Response {
55
55
 
56
56
  const response = new Response(null, {
57
57
  headers: {
58
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
58
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
59
59
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token, Idempotency-Key, X-Agent-Relay-Instance-Id, X-Agent-Relay-Epoch",
60
60
  "Access-Control-Max-Age": "600",
61
61
  },
@@ -97,6 +97,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
97
97
  if (pathname === "/api/events") return "events:read";
98
98
  if (pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
99
99
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
100
+ if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
100
101
  if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
101
102
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
102
103
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
package/src/types.ts CHANGED
@@ -184,6 +184,29 @@ export interface TaskStatusInput {
184
184
  metadata?: Record<string, unknown>;
185
185
  }
186
186
 
187
+ export interface InboxThreadState {
188
+ operatorId: string;
189
+ peerId: string;
190
+ readCursorMessageId?: number;
191
+ archivedAtMessageId?: number;
192
+ updatedAt: number;
193
+ }
194
+
195
+ export interface InboxDraft {
196
+ operatorId: string;
197
+ peerId: string;
198
+ body: string;
199
+ subject?: string;
200
+ channel?: string;
201
+ updatedAt: number;
202
+ }
203
+
204
+ export interface InboxState {
205
+ operatorId: string;
206
+ threads: InboxThreadState[];
207
+ drafts: InboxDraft[];
208
+ }
209
+
187
210
  export interface HealthCheck {
188
211
  name: string;
189
212
  status: "ok" | "warn" | "error";