agent-relay-server 0.5.0 → 0.6.1

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
@@ -5,13 +5,18 @@ import type {
5
5
  AgentCard,
6
6
  ActivityEvent,
7
7
  ActivityEventInput,
8
+ AgentKind,
8
9
  AgentSessionGuard,
10
+ ChannelBinding,
11
+ ChannelBindingMode,
12
+ ChannelRouteTarget,
13
+ ChannelSummary,
14
+ ChannelTargetHealth,
9
15
  CreatePairInput,
10
16
  HealthCheck,
11
17
  HealthReport,
12
18
  ManagedAgent,
13
19
  Message,
14
- MessageType,
15
20
  Orchestrator,
16
21
  OrchestratorStatus,
17
22
  PairActionInput,
@@ -48,6 +53,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
48
53
  CREATE TABLE IF NOT EXISTS agents (
49
54
  id TEXT PRIMARY KEY,
50
55
  name TEXT NOT NULL,
56
+ kind TEXT NOT NULL DEFAULT 'provider',
51
57
  tags TEXT NOT NULL DEFAULT '[]',
52
58
  machine TEXT,
53
59
  rig TEXT,
@@ -64,7 +70,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
64
70
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
71
  from_agent TEXT NOT NULL,
66
72
  to_target TEXT NOT NULL,
67
- type TEXT NOT NULL DEFAULT 'message',
73
+ kind TEXT NOT NULL DEFAULT 'chat',
68
74
  channel TEXT,
69
75
  subject TEXT,
70
76
  body TEXT NOT NULL,
@@ -75,6 +81,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
75
81
  claimed_at INTEGER,
76
82
  claim_expires_at INTEGER,
77
83
  idempotency_key TEXT,
84
+ payload TEXT NOT NULL DEFAULT '{}',
78
85
  meta TEXT NOT NULL DEFAULT '{}',
79
86
  read_by TEXT NOT NULL DEFAULT '[]',
80
87
  created_at INTEGER NOT NULL
@@ -199,6 +206,38 @@ export function initDb(path: string = "agent-relay.db"): Database {
199
206
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
200
207
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
201
208
 
209
+ CREATE TABLE IF NOT EXISTS channels (
210
+ id TEXT PRIMARY KEY,
211
+ provider TEXT NOT NULL,
212
+ account_id TEXT NOT NULL,
213
+ display_name TEXT NOT NULL,
214
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
215
+ transport TEXT NOT NULL,
216
+ direction TEXT NOT NULL DEFAULT 'bidirectional',
217
+ topic_channels TEXT NOT NULL DEFAULT '[]',
218
+ capabilities TEXT NOT NULL DEFAULT '[]',
219
+ meta TEXT NOT NULL DEFAULT '{}',
220
+ created_at INTEGER NOT NULL,
221
+ updated_at INTEGER NOT NULL
222
+ );
223
+ CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
224
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
225
+
226
+ CREATE TABLE IF NOT EXISTS channel_bindings (
227
+ id TEXT PRIMARY KEY,
228
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
229
+ conversation_key TEXT NOT NULL DEFAULT '',
230
+ conversation_id TEXT,
231
+ target_type TEXT NOT NULL,
232
+ target_id TEXT NOT NULL,
233
+ mode TEXT NOT NULL DEFAULT 'exclusive',
234
+ priority INTEGER NOT NULL DEFAULT 0,
235
+ created_at INTEGER NOT NULL,
236
+ updated_at INTEGER NOT NULL
237
+ );
238
+ CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
239
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
240
+
202
241
  CREATE TABLE IF NOT EXISTS orchestrators (
203
242
  id TEXT PRIMARY KEY,
204
243
  hostname TEXT NOT NULL,
@@ -238,9 +277,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
238
277
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
239
278
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
240
279
 
241
- if (!colNames.includes("type")) {
242
- db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
280
+ const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
281
+ if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
282
+ db.transaction(() => {
283
+ db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
284
+ db.run(`
285
+ CREATE TABLE channel_bindings (
286
+ id TEXT PRIMARY KEY,
287
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
288
+ conversation_key TEXT NOT NULL DEFAULT '',
289
+ conversation_id TEXT,
290
+ target_type TEXT NOT NULL,
291
+ target_id TEXT NOT NULL,
292
+ mode TEXT NOT NULL DEFAULT 'exclusive',
293
+ priority INTEGER NOT NULL DEFAULT 0,
294
+ created_at INTEGER NOT NULL,
295
+ updated_at INTEGER NOT NULL
296
+ )
297
+ `);
298
+ db.run(`
299
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
300
+ SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
301
+ FROM channel_bindings_old
302
+ `);
303
+ db.run("DROP TABLE channel_bindings_old");
304
+ })();
305
+ }
306
+ db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
307
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
308
+
309
+ if (!colNames.includes("kind")) {
310
+ db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
311
+ }
312
+ if (!colNames.includes("payload")) {
313
+ db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
243
314
  }
315
+ db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
244
316
 
245
317
  // Backfill thread_id for pre-migration rows (self-threaded).
246
318
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
@@ -280,19 +352,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
280
352
  if (!agentColNames.includes("epoch")) {
281
353
  db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
282
354
  }
355
+ if (!agentColNames.includes("kind")) {
356
+ db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
357
+ db.run(`
358
+ UPDATE agents
359
+ SET kind = CASE
360
+ WHEN id = 'user' THEN 'user'
361
+ WHEN id = 'system' THEN 'system'
362
+ WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
363
+ WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
364
+ ELSE 'provider'
365
+ END
366
+ `);
367
+ }
283
368
  db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
369
+ db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
284
370
 
285
371
  // Built-in agents — registered unconditionally so sends from these ids
286
372
  // pass the sendMessage validation. The reaper exempts these by checking
287
373
  // meta.builtin (or by id for "user").
288
374
  const now = Date.now();
289
375
  const builtinStmt = db.prepare(`
290
- INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
291
- VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
376
+ INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
377
+ VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
292
378
  ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
293
379
  `);
294
- builtinStmt.run("user", "User", '["human"]', now, now);
295
- builtinStmt.run("system", "System", '["system"]', now, now);
380
+ builtinStmt.run("user", "User", "user", '["human"]', now, now);
381
+ builtinStmt.run("system", "System", "system", '["system"]', now, now);
296
382
 
297
383
  // One-shot migration: backfill message_reads from legacy read_by JSON
298
384
  // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
@@ -333,7 +419,19 @@ function stringValue(value: unknown): string | undefined {
333
419
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
334
420
  }
335
421
 
422
+ function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
423
+ if (input.kind) return input.kind;
424
+ if (input.id === "user") return "user";
425
+ if (input.id === "system") return "system";
426
+ const metaKind = stringValue(input.meta?.kind);
427
+ if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
428
+ if (metaKind === "orchestrator") return "orchestrator";
429
+ if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
430
+ return "provider";
431
+ }
432
+
336
433
  function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
434
+ if (inferAgentKind(input) !== "provider") return undefined;
337
435
  const meta = input.meta ?? {};
338
436
  const values = [
339
437
  input.id,
@@ -367,6 +465,7 @@ function rowToAgent(row: any): AgentCard {
367
465
  return {
368
466
  id: row.id,
369
467
  name: row.name,
468
+ kind: row.kind ?? "provider",
370
469
  label: row.label ?? undefined,
371
470
  tags: parseStringArray(row.tags),
372
471
  machine: row.machine ?? undefined,
@@ -387,7 +486,7 @@ function rowToMessage(row: any): Message {
387
486
  id: row.id,
388
487
  from: row.from_agent,
389
488
  to: row.to_target,
390
- type: (row.type ?? "message") as MessageType,
489
+ kind: row.kind ?? "chat",
391
490
  channel: row.channel ?? undefined,
392
491
  subject: row.subject ?? undefined,
393
492
  body: row.body,
@@ -398,6 +497,7 @@ function rowToMessage(row: any): Message {
398
497
  claimedAt: row.claimed_at ?? undefined,
399
498
  claimExpiresAt: row.claim_expires_at ?? undefined,
400
499
  idempotencyKey: row.idempotency_key ?? undefined,
500
+ payload: parseJson(row.payload ?? "{}", {}),
401
501
  meta: parseJson(row.meta, {}),
402
502
  readBy: parseJson(row.read_by_agents ?? "[]", []),
403
503
  createdAt: row.created_at,
@@ -503,6 +603,154 @@ function rowToActivityEvent(row: any): ActivityEvent {
503
603
  };
504
604
  }
505
605
 
606
+ function rowToChannelBinding(row: any): ChannelBinding {
607
+ const target = row.target_type === "broadcast"
608
+ ? { type: "broadcast" } as ChannelRouteTarget
609
+ : { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
610
+ return {
611
+ id: row.id,
612
+ channelId: row.channel_id,
613
+ conversationId: row.conversation_id ?? undefined,
614
+ target,
615
+ mode: row.mode as ChannelBindingMode,
616
+ priority: row.priority,
617
+ createdAt: row.created_at,
618
+ updatedAt: row.updated_at,
619
+ };
620
+ }
621
+
622
+ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
623
+ if (target.type === "agent") return target.id;
624
+ if (target.type === "label") return `label:${target.id}`;
625
+ if (target.type === "tag") return `tag:${target.id}`;
626
+ if (target.type === "capability") return `cap:${target.id}`;
627
+ if (target.type === "broadcast") return "broadcast";
628
+ if (target.type === "orchestrator") return `orchestrator:${target.id}`;
629
+ return "";
630
+ }
631
+
632
+ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
633
+ const candidates = listAgents().filter((agent) => (
634
+ agent.id !== "user" &&
635
+ agent.id !== "system" &&
636
+ agent.kind !== "channel" &&
637
+ agent.meta?.kind !== "channel"
638
+ ));
639
+ if (target.type === "agent" || target.type === "orchestrator") {
640
+ const agent = getAgent(target.id);
641
+ return agent ? [agent] : [];
642
+ }
643
+ if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
644
+ if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
645
+ if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
646
+ if (target.type === "broadcast") return candidates;
647
+ return [];
648
+ }
649
+
650
+ function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
651
+ return {
652
+ id: agent.id,
653
+ name: agent.name,
654
+ status: agent.status,
655
+ ready: agent.ready,
656
+ lastSeen: agent.lastSeen,
657
+ label: agent.label,
658
+ tags: agent.tags,
659
+ capabilities: agent.capabilities,
660
+ };
661
+ }
662
+
663
+ function isHealthyChannelTarget(agent: AgentCard, now: number): boolean {
664
+ return isDeliveryAgent(agent) && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
665
+ }
666
+
667
+ function describeTarget(target: ChannelRouteTarget): string {
668
+ return bindingTargetToLegacyTarget(target);
669
+ }
670
+
671
+ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
672
+ const target = binding.target;
673
+ const targetLabel = describeTarget(target);
674
+ const matches = channelTargetMatches(target);
675
+ const snapshots = matches.map(channelTargetMatchSnapshot);
676
+
677
+ if (target.type === "agent" || target.type === "orchestrator") {
678
+ const agent = matches[0];
679
+ if (!agent) {
680
+ return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
681
+ }
682
+ if (agent.id !== "user" && agent.id !== "system" && (agent.kind === "channel" || agent.meta?.kind === "channel")) {
683
+ return { status: "error", detail: `Target ${targetLabel} is a channel, not a delivery agent`, target, matches: snapshots };
684
+ }
685
+ if (agent.status === "offline") {
686
+ return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
687
+ }
688
+ if (!agent.ready) {
689
+ return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
690
+ }
691
+ if (agent.id !== "user" && agent.id !== "system" && agent.lastSeen <= now - STALE_TTL_MS) {
692
+ return { status: "warning", detail: `Target ${targetLabel} heartbeat is stale`, target, matches: snapshots };
693
+ }
694
+ return { status: "ok", detail: `Target ${targetLabel} is online and ready`, target, matches: snapshots };
695
+ }
696
+
697
+ const healthyMatches = matches.filter((agent) => isHealthyChannelTarget(agent, now));
698
+ if (matches.length === 0) {
699
+ return { status: "error", detail: `Target ${targetLabel} has no matching agents`, target, matches: [] };
700
+ }
701
+ if (healthyMatches.length === 0) {
702
+ return { status: "error", detail: `Target ${targetLabel} has no online ready agents`, target, matches: snapshots };
703
+ }
704
+ if (binding.mode === "exclusive" && healthyMatches.length > 1) {
705
+ return {
706
+ status: "warning",
707
+ detail: `Target ${targetLabel} matches ${healthyMatches.length} online ready agents for an exclusive binding`,
708
+ target,
709
+ matches: snapshots,
710
+ };
711
+ }
712
+ return {
713
+ status: "ok",
714
+ detail: `Target ${targetLabel} has ${healthyMatches.length} online ready agent${healthyMatches.length === 1 ? "" : "s"}`,
715
+ target,
716
+ matches: snapshots,
717
+ };
718
+ }
719
+
720
+ function rowToChannelSummary(row: any): ChannelSummary {
721
+ const binding = row.binding_id ? rowToChannelBinding({
722
+ id: row.binding_id,
723
+ channel_id: row.id,
724
+ conversation_id: row.binding_conversation_id,
725
+ target_type: row.binding_target_type,
726
+ target_id: row.binding_target_id,
727
+ mode: row.binding_mode,
728
+ priority: row.binding_priority,
729
+ created_at: row.binding_created_at,
730
+ updated_at: row.binding_updated_at,
731
+ }) : undefined;
732
+
733
+ return {
734
+ id: row.id,
735
+ name: row.display_name,
736
+ type: row.provider,
737
+ transport: row.transport,
738
+ agentId: row.agent_id,
739
+ accountId: row.account_id,
740
+ status: row.agent_status,
741
+ ready: row.agent_ready === 1,
742
+ direction: row.direction,
743
+ target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
744
+ binding,
745
+ targetHealth: binding ? channelTargetHealth(binding) : undefined,
746
+ topicChannels: parseStringArray(row.topic_channels),
747
+ capabilities: parseStringArray(row.channel_capabilities),
748
+ tags: parseStringArray(row.agent_tags),
749
+ lastSeen: row.agent_last_seen,
750
+ meta: parseJson(row.channel_meta, {}),
751
+ };
752
+ }
753
+
506
754
  const MSG_SELECT = `SELECT m.*, (
507
755
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
508
756
  ) AS read_by_agents FROM messages m`;
@@ -511,6 +759,13 @@ const MSG_SELECT = `SELECT m.*, (
511
759
 
512
760
  export function upsertAgent(input: RegisterAgentInput): AgentCard {
513
761
  const now = Date.now();
762
+ const kind = inferAgentKind(input);
763
+ if (kind === "channel") {
764
+ const expectedId = expectedChannelAgentId(input);
765
+ if (input.id !== expectedId) {
766
+ throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
767
+ }
768
+ }
514
769
  const tags = tagsWithProvider(input);
515
770
  // Preserve the existing label across re-registrations unless the caller
516
771
  // explicitly sends one (including null to clear).
@@ -518,10 +773,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
518
773
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
519
774
  const instanceProvided = Boolean(input.instanceId);
520
775
  const stmt = db.prepare(`
521
- INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
522
- VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
776
+ INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
777
+ VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
523
778
  ON CONFLICT(id) DO UPDATE SET
524
779
  name = $name,
780
+ kind = $kind,
525
781
  label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
526
782
  tags = $tags,
527
783
  machine = coalesce($machine, agents.machine),
@@ -541,6 +797,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
541
797
  stmt.run({
542
798
  $id: input.id,
543
799
  $name: input.name,
800
+ $kind: kind,
544
801
  $label: input.label ?? null,
545
802
  $labelProvided: labelProvided ? 1 : 0,
546
803
  $tags: JSON.stringify(tags),
@@ -557,7 +814,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
557
814
  $now: now,
558
815
  });
559
816
 
560
- return getAgent(input.id)!;
817
+ const agent = getAgent(input.id)!;
818
+ if (agent.kind === "channel") upsertChannelForAgent(agent);
819
+ return agent;
561
820
  }
562
821
 
563
822
  export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
@@ -646,6 +905,207 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
646
905
  return result.changes > 0;
647
906
  }
648
907
 
908
+ function channelProviderForAgent(agent: AgentCard): string {
909
+ return stringValue(agent.meta?.provider) ??
910
+ stringValue(agent.meta?.channelType) ??
911
+ stringValue(agent.meta?.transport) ??
912
+ agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
913
+ "custom";
914
+ }
915
+
916
+ function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
917
+ return stringValue(input.meta?.provider) ??
918
+ stringValue(input.meta?.channelType) ??
919
+ stringValue(input.meta?.transport) ??
920
+ (input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
921
+ "custom";
922
+ }
923
+
924
+ function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
925
+ const accountId = stringValue(agent.meta?.accountId);
926
+ if (accountId) return accountId;
927
+ const prefix = `${provider}:`;
928
+ return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
929
+ }
930
+
931
+ function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
932
+ const accountId = stringValue(input.meta?.accountId);
933
+ if (accountId) return accountId;
934
+ const prefix = `${provider}:`;
935
+ return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
936
+ }
937
+
938
+ function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
939
+ const provider = channelProviderForInput(input);
940
+ const accountId = channelAccountIdForInput(input, provider);
941
+ return `${provider}:${accountId}`;
942
+ }
943
+
944
+ function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
945
+ const direction = stringValue(agent.meta?.direction);
946
+ return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
947
+ }
948
+
949
+ function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
950
+ if (!target) return undefined;
951
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
952
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
953
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
954
+ if (target === "broadcast") return { type: "broadcast" };
955
+ if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
956
+ return { type: "agent", id: target };
957
+ }
958
+
959
+ function upsertChannelForAgent(agent: AgentCard): void {
960
+ const now = Date.now();
961
+ const provider = channelProviderForAgent(agent);
962
+ const accountId = channelAccountIdForAgent(agent, provider);
963
+ const channelId = `${provider}:${accountId}`;
964
+ const transport = stringValue(agent.meta?.transport) ?? provider;
965
+ const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
966
+ const topicChannels = Array.isArray(agent.meta?.topicChannels)
967
+ ? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
968
+ : [];
969
+
970
+ db.prepare(`
971
+ INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
972
+ VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
973
+ ON CONFLICT(provider, account_id) DO UPDATE SET
974
+ id = $id,
975
+ provider = $provider,
976
+ account_id = $accountId,
977
+ display_name = $displayName,
978
+ agent_id = $agentId,
979
+ transport = $transport,
980
+ direction = $direction,
981
+ topic_channels = $topicChannels,
982
+ capabilities = $capabilities,
983
+ meta = $meta,
984
+ updated_at = $now
985
+ `).run({
986
+ $id: channelId,
987
+ $provider: provider,
988
+ $accountId: accountId,
989
+ $displayName: displayName,
990
+ $agentId: agent.id,
991
+ $transport: transport,
992
+ $direction: channelDirectionForAgent(agent),
993
+ $topicChannels: JSON.stringify(topicChannels),
994
+ $capabilities: JSON.stringify(agent.capabilities),
995
+ $meta: JSON.stringify(agent.meta ?? {}),
996
+ $now: now,
997
+ });
998
+
999
+ const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
1000
+ if (defaultTarget) {
1001
+ upsertChannelBinding({
1002
+ channelId,
1003
+ target: defaultTarget,
1004
+ mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
1005
+ });
1006
+ }
1007
+ }
1008
+
1009
+ export function listChannels(): ChannelSummary[] {
1010
+ const rows = db.prepare(`
1011
+ SELECT
1012
+ c.*,
1013
+ c.capabilities AS channel_capabilities,
1014
+ c.meta AS channel_meta,
1015
+ a.status AS agent_status,
1016
+ a.ready AS agent_ready,
1017
+ a.tags AS agent_tags,
1018
+ a.last_seen AS agent_last_seen,
1019
+ b.id AS binding_id,
1020
+ b.conversation_id AS binding_conversation_id,
1021
+ b.target_type AS binding_target_type,
1022
+ b.target_id AS binding_target_id,
1023
+ b.mode AS binding_mode,
1024
+ b.priority AS binding_priority,
1025
+ b.created_at AS binding_created_at,
1026
+ b.updated_at AS binding_updated_at
1027
+ FROM channels c
1028
+ JOIN agents a ON a.id = c.agent_id
1029
+ LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
1030
+ ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
1031
+ `).all() as any[];
1032
+ return rows.map(rowToChannelSummary);
1033
+ }
1034
+
1035
+ export function getChannel(channelId: string): ChannelSummary | null {
1036
+ return listChannels().find((channel) => channel.id === channelId) ?? null;
1037
+ }
1038
+
1039
+ export function listChannelBindings(channelId?: string): ChannelBinding[] {
1040
+ const rows = channelId
1041
+ ? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
1042
+ : db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
1043
+ return rows.map(rowToChannelBinding);
1044
+ }
1045
+
1046
+ export function upsertChannelBinding(input: {
1047
+ channelId: string;
1048
+ conversationId?: string;
1049
+ target: ChannelRouteTarget;
1050
+ mode?: ChannelBindingMode;
1051
+ priority?: number;
1052
+ }): ChannelBinding {
1053
+ if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
1054
+ const conversationKey = input.conversationId ?? "";
1055
+ const targetId = input.target.type === "broadcast" ? "" : input.target.id;
1056
+ const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
1057
+ const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
1058
+ const mode = input.mode ?? "exclusive";
1059
+ const now = Date.now();
1060
+ db.transaction(() => {
1061
+ if (mode === "exclusive") {
1062
+ db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
1063
+ }
1064
+ db.prepare(`
1065
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
1066
+ VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
1067
+ ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
1068
+ mode = $mode,
1069
+ priority = $priority,
1070
+ updated_at = $now
1071
+ `).run({
1072
+ $id: id,
1073
+ $channelId: input.channelId,
1074
+ $conversationKey: conversationKey,
1075
+ $conversationId: input.conversationId ?? null,
1076
+ $targetType: input.target.type,
1077
+ $targetId: targetId,
1078
+ $mode: mode,
1079
+ $priority: input.priority ?? 0,
1080
+ $now: now,
1081
+ });
1082
+ })();
1083
+
1084
+ const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
1085
+ return rowToChannelBinding(row);
1086
+ }
1087
+
1088
+ export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
1089
+ const rows = conversationId
1090
+ ? db.prepare(`
1091
+ SELECT * FROM channel_bindings
1092
+ WHERE channel_id = ? AND conversation_key IN (?, '')
1093
+ ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
1094
+ `).all(channelId, conversationId, conversationId) as any[]
1095
+ : db.prepare(`
1096
+ SELECT * FROM channel_bindings
1097
+ WHERE channel_id = ? AND conversation_key = ''
1098
+ ORDER BY priority DESC, updated_at DESC
1099
+ `).all(channelId) as any[];
1100
+ if (!conversationId) return rows.map(rowToChannelBinding);
1101
+ const exact = rows.filter((row) => row.conversation_key === conversationId);
1102
+ return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
1103
+ }
1104
+
1105
+ export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
1106
+ return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
1107
+ }
1108
+
649
1109
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
650
1110
  const now = Date.now();
651
1111
  const cutoff = now - ttlMs;
@@ -833,14 +1293,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
833
1293
  message = sendMessage({
834
1294
  from: "system",
835
1295
  to: task.target,
836
- type: "system",
1296
+ kind: "task",
837
1297
  channel: task.channel,
838
1298
  subject: `[${task.severity}] ${task.title}`,
839
1299
  body: taskMessageBody(task),
840
1300
  claimable: true,
841
- meta: {
1301
+ payload: {
842
1302
  taskId: task.id,
843
1303
  source: task.source,
1304
+ title: task.title,
1305
+ status: task.status,
844
1306
  severity: task.severity,
845
1307
  dedupeKey: task.dedupeKey ?? null,
846
1308
  externalUrl: task.externalUrl ?? null,
@@ -1175,10 +1637,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
1175
1637
  return sendMessage({
1176
1638
  from: "system",
1177
1639
  to,
1178
- type: "system",
1640
+ kind: "pair",
1179
1641
  subject,
1180
1642
  body,
1181
- meta: {
1643
+ payload: {
1182
1644
  pairId: pair.id,
1183
1645
  pairEvent: event,
1184
1646
  requesterId: pair.requesterId,
@@ -1326,9 +1788,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
1326
1788
  const message = sendMessage({
1327
1789
  from: input.from,
1328
1790
  to,
1791
+ kind: "pair",
1329
1792
  subject: input.subject ?? `Pair ${pair.id}`,
1330
1793
  body: input.body,
1331
- meta: {
1794
+ payload: {
1332
1795
  pairId: pair.id,
1333
1796
  pairEvent: "message",
1334
1797
  requesterId: pair.requesterId,
@@ -1352,9 +1815,35 @@ function isDeliveryAgent(agent: AgentCard): boolean {
1352
1815
  return agent.status !== "offline" &&
1353
1816
  agent.id !== "user" &&
1354
1817
  agent.id !== "system" &&
1818
+ agent.kind !== "channel" &&
1355
1819
  agent.meta?.kind !== "channel";
1356
1820
  }
1357
1821
 
1822
+ function isChannelAgentId(agentId: string): boolean {
1823
+ const agent = getAgent(agentId);
1824
+ return Boolean(agent && (
1825
+ agent.kind === "channel" ||
1826
+ agent.meta?.kind === "channel" ||
1827
+ agent.tags.includes("channel") ||
1828
+ agent.capabilities.includes("channel")
1829
+ ));
1830
+ }
1831
+
1832
+ function legacyChannelTargets(agent: AgentCard | null | undefined): string[] {
1833
+ if (!agent || !isChannelAgentId(agent.id)) return [];
1834
+ const aliases = new Set<string>();
1835
+ const provider = channelProviderForAgent(agent);
1836
+ if (provider && provider !== "custom") aliases.add(provider);
1837
+ const channelType = stringValue(agent.meta?.channelType);
1838
+ if (channelType) aliases.add(channelType);
1839
+ const transport = stringValue(agent.meta?.transport);
1840
+ if (transport) aliases.add(transport);
1841
+ const providerTag = agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length);
1842
+ if (providerTag) aliases.add(providerTag);
1843
+ aliases.delete(agent.id);
1844
+ return [...aliases];
1845
+ }
1846
+
1358
1847
  function matchingDeliveryAgents(target: string): AgentCard[] {
1359
1848
  if (!target) return [];
1360
1849
  const candidates = listAgents().filter(isDeliveryAgent);
@@ -1382,10 +1871,18 @@ function claimableAllowedForTarget(target: string): boolean {
1382
1871
 
1383
1872
  function shouldStoreClaimable(input: SendMessageInput): boolean {
1384
1873
  if (!input.claimable) return false;
1385
- if (input.type === "system" || typeof input.meta?.taskId === "number") return true;
1874
+ if (input.kind === "task" || input.kind === "system") return true;
1875
+ if (input.kind === "channel.event") return true;
1386
1876
  return claimableAllowedForTarget(input.to);
1387
1877
  }
1388
1878
 
1879
+ function inferMessageKind(input: SendMessageInput): Message["kind"] {
1880
+ if (input.kind) return input.kind;
1881
+ if (input.claimable) return "task";
1882
+ if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
1883
+ return "chat";
1884
+ }
1885
+
1389
1886
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
1390
1887
  const now = Date.now();
1391
1888
 
@@ -1408,17 +1905,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1408
1905
  }
1409
1906
 
1410
1907
  const insert = db.prepare(`
1411
- INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, meta, created_at)
1412
- VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
1908
+ INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
1909
+ VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
1413
1910
  `);
1414
1911
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
1415
1912
  const claimable = shouldStoreClaimable(input);
1913
+ const kind = inferMessageKind(input);
1416
1914
 
1417
1915
  const id = db.transaction(() => {
1418
1916
  const result = insert.run({
1419
1917
  $from: input.from,
1420
1918
  $to: input.to,
1421
- $type: input.type ?? "message",
1919
+ $kind: kind,
1422
1920
  $channel: input.channel ?? null,
1423
1921
  $subject: input.subject ?? null,
1424
1922
  $body: input.body,
@@ -1426,6 +1924,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1426
1924
  $replyTo: input.replyTo ?? null,
1427
1925
  $claimable: claimable ? 1 : 0,
1428
1926
  $idempotencyKey: input.idempotencyKey ?? null,
1927
+ $payload: JSON.stringify(input.payload ?? {}),
1429
1928
  $meta: JSON.stringify(input.meta ?? {}),
1430
1929
  $now: now,
1431
1930
  });
@@ -1483,8 +1982,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
1483
1982
  const messageClaim = claimMessageRow(messageId, agentId, now);
1484
1983
  if (!messageClaim.ok) return messageClaim;
1485
1984
 
1486
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1487
- ? msg.meta.taskId
1985
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
1986
+ ? msg.payload.taskId
1488
1987
  : null;
1489
1988
  if (!taskId) return { ok: true };
1490
1989
 
@@ -1534,8 +2033,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
1534
2033
  let task: Task | undefined;
1535
2034
  db.transaction(() => {
1536
2035
  db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
1537
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1538
- ? msg.meta.taskId
2036
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
2037
+ ? msg.payload.taskId
1539
2038
  : null;
1540
2039
  if (taskId) {
1541
2040
  db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
@@ -1575,11 +2074,15 @@ export function pollMessages(query: PollQuery): Message[] {
1575
2074
  const agentTags = agent?.tags ?? [];
1576
2075
  const agentCaps = agent?.capabilities ?? [];
1577
2076
  const agentLabel = agent?.label;
2077
+ const agentLegacyChannelTargets = legacyChannelTargets(agent);
1578
2078
 
1579
2079
  const conditions: string[] = [];
1580
2080
  const params: any[] = [];
1581
2081
 
1582
- // Build target matching: direct + broadcast + tag + capability + label
2082
+ // Build target matching: direct + broadcast + tag + capability + label.
2083
+ // Channel agents also accept legacy bare provider targets (for example
2084
+ // "telegram") so older clients keep working after canonical IDs became
2085
+ // provider:account ("telegram:default").
1583
2086
  const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
1584
2087
  params.push(query.for);
1585
2088
 
@@ -1595,6 +2098,10 @@ export function pollMessages(query: PollQuery): Message[] {
1595
2098
  targetClauses.push("to_target = ?");
1596
2099
  params.push(`label:${agentLabel}`);
1597
2100
  }
2101
+ for (const target of agentLegacyChannelTargets) {
2102
+ targetClauses.push("to_target = ?");
2103
+ params.push(target);
2104
+ }
1598
2105
  conditions.push(`(${targetClauses.join(" OR ")})`);
1599
2106
 
1600
2107
  // Hide active claims held by someone else, but let expired claims surface so
@@ -1879,6 +2386,21 @@ export function getHealth(now: number = Date.now()): HealthReport {
1879
2386
  detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
1880
2387
  });
1881
2388
 
2389
+ const unhealthyChannelTargets = listChannels().filter((channel) => channel.targetHealth && channel.targetHealth.status !== "ok");
2390
+ const channelTargetErrors = unhealthyChannelTargets.filter((channel) => channel.targetHealth?.status === "error");
2391
+ const channelTargetDetail = unhealthyChannelTargets
2392
+ .slice(0, 3)
2393
+ .map((channel) => `${channel.name}: ${channel.targetHealth?.detail}`)
2394
+ .join("; ");
2395
+ checks.push({
2396
+ name: "channel-delivery-targets",
2397
+ status: channelTargetErrors.length > 0 ? "error" : unhealthyChannelTargets.length > 0 ? "warn" : "ok",
2398
+ count: unhealthyChannelTargets.length,
2399
+ detail: unhealthyChannelTargets.length > 0
2400
+ ? `${unhealthyChannelTargets.length} channel delivery target issue(s): ${channelTargetDetail}${unhealthyChannelTargets.length > 3 ? "; ..." : ""}`
2401
+ : undefined,
2402
+ });
2403
+
1882
2404
  const status = checks.some((check) => check.status === "error")
1883
2405
  ? "error"
1884
2406
  : checks.some((check) => check.status === "warn")