agent-relay-server 0.4.39 → 0.6.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/db.ts CHANGED
@@ -5,19 +5,29 @@ import type {
5
5
  AgentCard,
6
6
  ActivityEvent,
7
7
  ActivityEventInput,
8
+ AgentKind,
8
9
  AgentSessionGuard,
10
+ ChannelBinding,
11
+ ChannelBindingMode,
12
+ ChannelRouteTarget,
13
+ ChannelSummary,
9
14
  CreatePairInput,
10
15
  HealthCheck,
11
16
  HealthReport,
17
+ ManagedAgent,
12
18
  Message,
13
- MessageType,
19
+ Orchestrator,
20
+ OrchestratorStatus,
14
21
  PairActionInput,
15
22
  PairMessageInput,
16
23
  PairSession,
17
24
  PairStatus,
18
25
  RegisterAgentInput,
26
+ RegisterOrchestratorInput,
19
27
  SendMessageInput,
20
28
  PollQuery,
29
+ SpawnApprovalMode,
30
+ SpawnProvider,
21
31
  Task,
22
32
  TaskEvent,
23
33
  TaskSeverity,
@@ -42,6 +52,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
42
52
  CREATE TABLE IF NOT EXISTS agents (
43
53
  id TEXT PRIMARY KEY,
44
54
  name TEXT NOT NULL,
55
+ kind TEXT NOT NULL DEFAULT 'provider',
45
56
  tags TEXT NOT NULL DEFAULT '[]',
46
57
  machine TEXT,
47
58
  rig TEXT,
@@ -58,7 +69,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
58
69
  id INTEGER PRIMARY KEY AUTOINCREMENT,
59
70
  from_agent TEXT NOT NULL,
60
71
  to_target TEXT NOT NULL,
61
- type TEXT NOT NULL DEFAULT 'message',
72
+ kind TEXT NOT NULL DEFAULT 'chat',
62
73
  channel TEXT,
63
74
  subject TEXT,
64
75
  body TEXT NOT NULL,
@@ -69,6 +80,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
69
80
  claimed_at INTEGER,
70
81
  claim_expires_at INTEGER,
71
82
  idempotency_key TEXT,
83
+ payload TEXT NOT NULL DEFAULT '{}',
72
84
  meta TEXT NOT NULL DEFAULT '{}',
73
85
  read_by TEXT NOT NULL DEFAULT '[]',
74
86
  created_at INTEGER NOT NULL
@@ -192,6 +204,52 @@ export function initDb(path: string = "agent-relay.db"): Database {
192
204
  );
193
205
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
194
206
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
207
+
208
+ CREATE TABLE IF NOT EXISTS channels (
209
+ id TEXT PRIMARY KEY,
210
+ provider TEXT NOT NULL,
211
+ account_id TEXT NOT NULL,
212
+ display_name TEXT NOT NULL,
213
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
214
+ transport TEXT NOT NULL,
215
+ direction TEXT NOT NULL DEFAULT 'bidirectional',
216
+ topic_channels TEXT NOT NULL DEFAULT '[]',
217
+ capabilities TEXT NOT NULL DEFAULT '[]',
218
+ meta TEXT NOT NULL DEFAULT '{}',
219
+ created_at INTEGER NOT NULL,
220
+ updated_at INTEGER NOT NULL
221
+ );
222
+ CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
223
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
224
+
225
+ CREATE TABLE IF NOT EXISTS channel_bindings (
226
+ id TEXT PRIMARY KEY,
227
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
228
+ conversation_key TEXT NOT NULL DEFAULT '',
229
+ conversation_id TEXT,
230
+ target_type TEXT NOT NULL,
231
+ target_id TEXT NOT NULL,
232
+ mode TEXT NOT NULL DEFAULT 'exclusive',
233
+ priority INTEGER NOT NULL DEFAULT 0,
234
+ created_at INTEGER NOT NULL,
235
+ updated_at INTEGER NOT NULL
236
+ );
237
+ CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
238
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
239
+
240
+ CREATE TABLE IF NOT EXISTS orchestrators (
241
+ id TEXT PRIMARY KEY,
242
+ hostname TEXT NOT NULL,
243
+ status TEXT NOT NULL DEFAULT 'online',
244
+ agent_id TEXT NOT NULL,
245
+ providers TEXT NOT NULL DEFAULT '[]',
246
+ base_dir TEXT NOT NULL,
247
+ env_keys TEXT NOT NULL DEFAULT '[]',
248
+ meta TEXT NOT NULL DEFAULT '{}',
249
+ managed_agents TEXT NOT NULL DEFAULT '[]',
250
+ last_seen INTEGER NOT NULL,
251
+ created_at INTEGER NOT NULL
252
+ );
195
253
  `);
196
254
 
197
255
  // Migrations
@@ -218,9 +276,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
218
276
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
219
277
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
220
278
 
221
- if (!colNames.includes("type")) {
222
- db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
279
+ const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
280
+ if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
281
+ db.transaction(() => {
282
+ db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
283
+ db.run(`
284
+ CREATE TABLE channel_bindings (
285
+ id TEXT PRIMARY KEY,
286
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
287
+ conversation_key TEXT NOT NULL DEFAULT '',
288
+ conversation_id TEXT,
289
+ target_type TEXT NOT NULL,
290
+ target_id TEXT NOT NULL,
291
+ mode TEXT NOT NULL DEFAULT 'exclusive',
292
+ priority INTEGER NOT NULL DEFAULT 0,
293
+ created_at INTEGER NOT NULL,
294
+ updated_at INTEGER NOT NULL
295
+ )
296
+ `);
297
+ db.run(`
298
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
299
+ SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
300
+ FROM channel_bindings_old
301
+ `);
302
+ db.run("DROP TABLE channel_bindings_old");
303
+ })();
223
304
  }
305
+ db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
306
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
307
+
308
+ if (!colNames.includes("kind")) {
309
+ db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
310
+ }
311
+ if (!colNames.includes("payload")) {
312
+ db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
313
+ }
314
+ db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
224
315
 
225
316
  // Backfill thread_id for pre-migration rows (self-threaded).
226
317
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
@@ -260,19 +351,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
260
351
  if (!agentColNames.includes("epoch")) {
261
352
  db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
262
353
  }
354
+ if (!agentColNames.includes("kind")) {
355
+ db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
356
+ db.run(`
357
+ UPDATE agents
358
+ SET kind = CASE
359
+ WHEN id = 'user' THEN 'user'
360
+ WHEN id = 'system' THEN 'system'
361
+ WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
362
+ WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
363
+ ELSE 'provider'
364
+ END
365
+ `);
366
+ }
263
367
  db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
368
+ db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
264
369
 
265
370
  // Built-in agents — registered unconditionally so sends from these ids
266
371
  // pass the sendMessage validation. The reaper exempts these by checking
267
372
  // meta.builtin (or by id for "user").
268
373
  const now = Date.now();
269
374
  const builtinStmt = db.prepare(`
270
- INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
271
- VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
375
+ INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
376
+ VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
272
377
  ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
273
378
  `);
274
- builtinStmt.run("user", "User", '["human"]', now, now);
275
- builtinStmt.run("system", "System", '["system"]', now, now);
379
+ builtinStmt.run("user", "User", "user", '["human"]', now, now);
380
+ builtinStmt.run("system", "System", "system", '["system"]', now, now);
276
381
 
277
382
  // One-shot migration: backfill message_reads from legacy read_by JSON
278
383
  // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
@@ -313,7 +418,19 @@ function stringValue(value: unknown): string | undefined {
313
418
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
314
419
  }
315
420
 
421
+ function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
422
+ if (input.kind) return input.kind;
423
+ if (input.id === "user") return "user";
424
+ if (input.id === "system") return "system";
425
+ const metaKind = stringValue(input.meta?.kind);
426
+ if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
427
+ if (metaKind === "orchestrator") return "orchestrator";
428
+ if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
429
+ return "provider";
430
+ }
431
+
316
432
  function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
433
+ if (inferAgentKind(input) !== "provider") return undefined;
317
434
  const meta = input.meta ?? {};
318
435
  const values = [
319
436
  input.id,
@@ -347,6 +464,7 @@ function rowToAgent(row: any): AgentCard {
347
464
  return {
348
465
  id: row.id,
349
466
  name: row.name,
467
+ kind: row.kind ?? "provider",
350
468
  label: row.label ?? undefined,
351
469
  tags: parseStringArray(row.tags),
352
470
  machine: row.machine ?? undefined,
@@ -367,7 +485,7 @@ function rowToMessage(row: any): Message {
367
485
  id: row.id,
368
486
  from: row.from_agent,
369
487
  to: row.to_target,
370
- type: (row.type ?? "message") as MessageType,
488
+ kind: row.kind ?? "chat",
371
489
  channel: row.channel ?? undefined,
372
490
  subject: row.subject ?? undefined,
373
491
  body: row.body,
@@ -378,6 +496,7 @@ function rowToMessage(row: any): Message {
378
496
  claimedAt: row.claimed_at ?? undefined,
379
497
  claimExpiresAt: row.claim_expires_at ?? undefined,
380
498
  idempotencyKey: row.idempotency_key ?? undefined,
499
+ payload: parseJson(row.payload ?? "{}", {}),
381
500
  meta: parseJson(row.meta, {}),
382
501
  readBy: parseJson(row.read_by_agents ?? "[]", []),
383
502
  createdAt: row.created_at,
@@ -483,6 +602,65 @@ function rowToActivityEvent(row: any): ActivityEvent {
483
602
  };
484
603
  }
485
604
 
605
+ function rowToChannelBinding(row: any): ChannelBinding {
606
+ const target = row.target_type === "broadcast"
607
+ ? { type: "broadcast" } as ChannelRouteTarget
608
+ : { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
609
+ return {
610
+ id: row.id,
611
+ channelId: row.channel_id,
612
+ conversationId: row.conversation_id ?? undefined,
613
+ target,
614
+ mode: row.mode as ChannelBindingMode,
615
+ priority: row.priority,
616
+ createdAt: row.created_at,
617
+ updatedAt: row.updated_at,
618
+ };
619
+ }
620
+
621
+ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
622
+ if (target.type === "agent") return target.id;
623
+ if (target.type === "label") return `label:${target.id}`;
624
+ if (target.type === "tag") return `tag:${target.id}`;
625
+ if (target.type === "capability") return `cap:${target.id}`;
626
+ if (target.type === "broadcast") return "broadcast";
627
+ if (target.type === "orchestrator") return `orchestrator:${target.id}`;
628
+ return "";
629
+ }
630
+
631
+ function rowToChannelSummary(row: any): ChannelSummary {
632
+ const binding = row.binding_id ? rowToChannelBinding({
633
+ id: row.binding_id,
634
+ channel_id: row.id,
635
+ conversation_id: row.binding_conversation_id,
636
+ target_type: row.binding_target_type,
637
+ target_id: row.binding_target_id,
638
+ mode: row.binding_mode,
639
+ priority: row.binding_priority,
640
+ created_at: row.binding_created_at,
641
+ updated_at: row.binding_updated_at,
642
+ }) : undefined;
643
+
644
+ return {
645
+ id: row.id,
646
+ name: row.display_name,
647
+ type: row.provider,
648
+ transport: row.transport,
649
+ agentId: row.agent_id,
650
+ accountId: row.account_id,
651
+ status: row.agent_status,
652
+ ready: row.agent_ready === 1,
653
+ direction: row.direction,
654
+ target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
655
+ binding,
656
+ topicChannels: parseStringArray(row.topic_channels),
657
+ capabilities: parseStringArray(row.channel_capabilities),
658
+ tags: parseStringArray(row.agent_tags),
659
+ lastSeen: row.agent_last_seen,
660
+ meta: parseJson(row.channel_meta, {}),
661
+ };
662
+ }
663
+
486
664
  const MSG_SELECT = `SELECT m.*, (
487
665
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
488
666
  ) AS read_by_agents FROM messages m`;
@@ -491,6 +669,13 @@ const MSG_SELECT = `SELECT m.*, (
491
669
 
492
670
  export function upsertAgent(input: RegisterAgentInput): AgentCard {
493
671
  const now = Date.now();
672
+ const kind = inferAgentKind(input);
673
+ if (kind === "channel") {
674
+ const expectedId = expectedChannelAgentId(input);
675
+ if (input.id !== expectedId) {
676
+ throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
677
+ }
678
+ }
494
679
  const tags = tagsWithProvider(input);
495
680
  // Preserve the existing label across re-registrations unless the caller
496
681
  // explicitly sends one (including null to clear).
@@ -498,10 +683,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
498
683
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
499
684
  const instanceProvided = Boolean(input.instanceId);
500
685
  const stmt = db.prepare(`
501
- INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
502
- VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
686
+ INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
687
+ VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
503
688
  ON CONFLICT(id) DO UPDATE SET
504
689
  name = $name,
690
+ kind = $kind,
505
691
  label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
506
692
  tags = $tags,
507
693
  machine = coalesce($machine, agents.machine),
@@ -521,6 +707,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
521
707
  stmt.run({
522
708
  $id: input.id,
523
709
  $name: input.name,
710
+ $kind: kind,
524
711
  $label: input.label ?? null,
525
712
  $labelProvided: labelProvided ? 1 : 0,
526
713
  $tags: JSON.stringify(tags),
@@ -537,7 +724,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
537
724
  $now: now,
538
725
  });
539
726
 
540
- return getAgent(input.id)!;
727
+ const agent = getAgent(input.id)!;
728
+ if (agent.kind === "channel") upsertChannelForAgent(agent);
729
+ return agent;
541
730
  }
542
731
 
543
732
  export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
@@ -626,6 +815,201 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
626
815
  return result.changes > 0;
627
816
  }
628
817
 
818
+ function channelProviderForAgent(agent: AgentCard): string {
819
+ return stringValue(agent.meta?.provider) ??
820
+ stringValue(agent.meta?.channelType) ??
821
+ stringValue(agent.meta?.transport) ??
822
+ agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
823
+ "custom";
824
+ }
825
+
826
+ function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
827
+ return stringValue(input.meta?.provider) ??
828
+ stringValue(input.meta?.channelType) ??
829
+ stringValue(input.meta?.transport) ??
830
+ (input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
831
+ "custom";
832
+ }
833
+
834
+ function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
835
+ const accountId = stringValue(agent.meta?.accountId);
836
+ if (accountId) return accountId;
837
+ const prefix = `${provider}:`;
838
+ return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
839
+ }
840
+
841
+ function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
842
+ const accountId = stringValue(input.meta?.accountId);
843
+ if (accountId) return accountId;
844
+ const prefix = `${provider}:`;
845
+ return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
846
+ }
847
+
848
+ function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
849
+ const provider = channelProviderForInput(input);
850
+ const accountId = channelAccountIdForInput(input, provider);
851
+ return `${provider}:${accountId}`;
852
+ }
853
+
854
+ function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
855
+ const direction = stringValue(agent.meta?.direction);
856
+ return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
857
+ }
858
+
859
+ function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
860
+ if (!target) return undefined;
861
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
862
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
863
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
864
+ if (target === "broadcast") return { type: "broadcast" };
865
+ if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
866
+ return { type: "agent", id: target };
867
+ }
868
+
869
+ function upsertChannelForAgent(agent: AgentCard): void {
870
+ const now = Date.now();
871
+ const provider = channelProviderForAgent(agent);
872
+ const accountId = channelAccountIdForAgent(agent, provider);
873
+ const channelId = `${provider}:${accountId}`;
874
+ const transport = stringValue(agent.meta?.transport) ?? provider;
875
+ const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
876
+ const topicChannels = Array.isArray(agent.meta?.topicChannels)
877
+ ? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
878
+ : [];
879
+
880
+ db.prepare(`
881
+ INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
882
+ VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
883
+ ON CONFLICT(provider, account_id) DO UPDATE SET
884
+ id = $id,
885
+ provider = $provider,
886
+ account_id = $accountId,
887
+ display_name = $displayName,
888
+ agent_id = $agentId,
889
+ transport = $transport,
890
+ direction = $direction,
891
+ topic_channels = $topicChannels,
892
+ capabilities = $capabilities,
893
+ meta = $meta,
894
+ updated_at = $now
895
+ `).run({
896
+ $id: channelId,
897
+ $provider: provider,
898
+ $accountId: accountId,
899
+ $displayName: displayName,
900
+ $agentId: agent.id,
901
+ $transport: transport,
902
+ $direction: channelDirectionForAgent(agent),
903
+ $topicChannels: JSON.stringify(topicChannels),
904
+ $capabilities: JSON.stringify(agent.capabilities),
905
+ $meta: JSON.stringify(agent.meta ?? {}),
906
+ $now: now,
907
+ });
908
+
909
+ const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
910
+ if (defaultTarget) {
911
+ upsertChannelBinding({
912
+ channelId,
913
+ target: defaultTarget,
914
+ mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
915
+ });
916
+ }
917
+ }
918
+
919
+ export function listChannels(): ChannelSummary[] {
920
+ const rows = db.prepare(`
921
+ SELECT
922
+ c.*,
923
+ c.capabilities AS channel_capabilities,
924
+ c.meta AS channel_meta,
925
+ a.status AS agent_status,
926
+ a.ready AS agent_ready,
927
+ a.tags AS agent_tags,
928
+ a.last_seen AS agent_last_seen,
929
+ b.id AS binding_id,
930
+ b.conversation_id AS binding_conversation_id,
931
+ b.target_type AS binding_target_type,
932
+ b.target_id AS binding_target_id,
933
+ b.mode AS binding_mode,
934
+ b.priority AS binding_priority,
935
+ b.created_at AS binding_created_at,
936
+ b.updated_at AS binding_updated_at
937
+ FROM channels c
938
+ JOIN agents a ON a.id = c.agent_id
939
+ LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
940
+ ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
941
+ `).all() as any[];
942
+ return rows.map(rowToChannelSummary);
943
+ }
944
+
945
+ export function getChannel(channelId: string): ChannelSummary | null {
946
+ return listChannels().find((channel) => channel.id === channelId) ?? null;
947
+ }
948
+
949
+ export function listChannelBindings(channelId?: string): ChannelBinding[] {
950
+ const rows = channelId
951
+ ? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
952
+ : db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
953
+ return rows.map(rowToChannelBinding);
954
+ }
955
+
956
+ export function upsertChannelBinding(input: {
957
+ channelId: string;
958
+ conversationId?: string;
959
+ target: ChannelRouteTarget;
960
+ mode?: ChannelBindingMode;
961
+ priority?: number;
962
+ }): ChannelBinding {
963
+ if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
964
+ const conversationKey = input.conversationId ?? "";
965
+ const targetId = input.target.type === "broadcast" ? "" : input.target.id;
966
+ const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
967
+ const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
968
+ const now = Date.now();
969
+ db.prepare(`
970
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
971
+ VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
972
+ ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
973
+ mode = $mode,
974
+ priority = $priority,
975
+ updated_at = $now
976
+ `).run({
977
+ $id: id,
978
+ $channelId: input.channelId,
979
+ $conversationKey: conversationKey,
980
+ $conversationId: input.conversationId ?? null,
981
+ $targetType: input.target.type,
982
+ $targetId: targetId,
983
+ $mode: input.mode ?? "exclusive",
984
+ $priority: input.priority ?? 0,
985
+ $now: now,
986
+ });
987
+
988
+ const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
989
+ return rowToChannelBinding(row);
990
+ }
991
+
992
+ export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
993
+ const rows = conversationId
994
+ ? db.prepare(`
995
+ SELECT * FROM channel_bindings
996
+ WHERE channel_id = ? AND conversation_key IN (?, '')
997
+ ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
998
+ `).all(channelId, conversationId, conversationId) as any[]
999
+ : db.prepare(`
1000
+ SELECT * FROM channel_bindings
1001
+ WHERE channel_id = ? AND conversation_key = ''
1002
+ ORDER BY priority DESC, updated_at DESC
1003
+ `).all(channelId) as any[];
1004
+ if (!conversationId) return rows.map(rowToChannelBinding);
1005
+ const exact = rows.filter((row) => row.conversation_key === conversationId);
1006
+ return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
1007
+ }
1008
+
1009
+ export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
1010
+ return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
1011
+ }
1012
+
629
1013
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
630
1014
  const now = Date.now();
631
1015
  const cutoff = now - ttlMs;
@@ -813,14 +1197,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
813
1197
  message = sendMessage({
814
1198
  from: "system",
815
1199
  to: task.target,
816
- type: "system",
1200
+ kind: "task",
817
1201
  channel: task.channel,
818
1202
  subject: `[${task.severity}] ${task.title}`,
819
1203
  body: taskMessageBody(task),
820
1204
  claimable: true,
821
- meta: {
1205
+ payload: {
822
1206
  taskId: task.id,
823
1207
  source: task.source,
1208
+ title: task.title,
1209
+ status: task.status,
824
1210
  severity: task.severity,
825
1211
  dedupeKey: task.dedupeKey ?? null,
826
1212
  externalUrl: task.externalUrl ?? null,
@@ -1155,10 +1541,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
1155
1541
  return sendMessage({
1156
1542
  from: "system",
1157
1543
  to,
1158
- type: "system",
1544
+ kind: "pair",
1159
1545
  subject,
1160
1546
  body,
1161
- meta: {
1547
+ payload: {
1162
1548
  pairId: pair.id,
1163
1549
  pairEvent: event,
1164
1550
  requesterId: pair.requesterId,
@@ -1306,9 +1692,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
1306
1692
  const message = sendMessage({
1307
1693
  from: input.from,
1308
1694
  to,
1695
+ kind: "pair",
1309
1696
  subject: input.subject ?? `Pair ${pair.id}`,
1310
1697
  body: input.body,
1311
- meta: {
1698
+ payload: {
1312
1699
  pairId: pair.id,
1313
1700
  pairEvent: "message",
1314
1701
  requesterId: pair.requesterId,
@@ -1332,9 +1719,20 @@ function isDeliveryAgent(agent: AgentCard): boolean {
1332
1719
  return agent.status !== "offline" &&
1333
1720
  agent.id !== "user" &&
1334
1721
  agent.id !== "system" &&
1722
+ agent.kind !== "channel" &&
1335
1723
  agent.meta?.kind !== "channel";
1336
1724
  }
1337
1725
 
1726
+ function isChannelAgentId(agentId: string): boolean {
1727
+ const agent = getAgent(agentId);
1728
+ return Boolean(agent && (
1729
+ agent.kind === "channel" ||
1730
+ agent.meta?.kind === "channel" ||
1731
+ agent.tags.includes("channel") ||
1732
+ agent.capabilities.includes("channel")
1733
+ ));
1734
+ }
1735
+
1338
1736
  function matchingDeliveryAgents(target: string): AgentCard[] {
1339
1737
  if (!target) return [];
1340
1738
  const candidates = listAgents().filter(isDeliveryAgent);
@@ -1362,10 +1760,18 @@ function claimableAllowedForTarget(target: string): boolean {
1362
1760
 
1363
1761
  function shouldStoreClaimable(input: SendMessageInput): boolean {
1364
1762
  if (!input.claimable) return false;
1365
- if (input.type === "system" || typeof input.meta?.taskId === "number") return true;
1763
+ if (input.kind === "task" || input.kind === "system") return true;
1764
+ if (input.kind === "channel.event") return true;
1366
1765
  return claimableAllowedForTarget(input.to);
1367
1766
  }
1368
1767
 
1768
+ function inferMessageKind(input: SendMessageInput): Message["kind"] {
1769
+ if (input.kind) return input.kind;
1770
+ if (input.claimable) return "task";
1771
+ if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
1772
+ return "chat";
1773
+ }
1774
+
1369
1775
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
1370
1776
  const now = Date.now();
1371
1777
 
@@ -1388,17 +1794,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1388
1794
  }
1389
1795
 
1390
1796
  const insert = db.prepare(`
1391
- INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, meta, created_at)
1392
- VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
1797
+ INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
1798
+ VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
1393
1799
  `);
1394
1800
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
1395
1801
  const claimable = shouldStoreClaimable(input);
1802
+ const kind = inferMessageKind(input);
1396
1803
 
1397
1804
  const id = db.transaction(() => {
1398
1805
  const result = insert.run({
1399
1806
  $from: input.from,
1400
1807
  $to: input.to,
1401
- $type: input.type ?? "message",
1808
+ $kind: kind,
1402
1809
  $channel: input.channel ?? null,
1403
1810
  $subject: input.subject ?? null,
1404
1811
  $body: input.body,
@@ -1406,6 +1813,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1406
1813
  $replyTo: input.replyTo ?? null,
1407
1814
  $claimable: claimable ? 1 : 0,
1408
1815
  $idempotencyKey: input.idempotencyKey ?? null,
1816
+ $payload: JSON.stringify(input.payload ?? {}),
1409
1817
  $meta: JSON.stringify(input.meta ?? {}),
1410
1818
  $now: now,
1411
1819
  });
@@ -1463,8 +1871,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
1463
1871
  const messageClaim = claimMessageRow(messageId, agentId, now);
1464
1872
  if (!messageClaim.ok) return messageClaim;
1465
1873
 
1466
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1467
- ? msg.meta.taskId
1874
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
1875
+ ? msg.payload.taskId
1468
1876
  : null;
1469
1877
  if (!taskId) return { ok: true };
1470
1878
 
@@ -1514,8 +1922,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
1514
1922
  let task: Task | undefined;
1515
1923
  db.transaction(() => {
1516
1924
  db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
1517
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1518
- ? msg.meta.taskId
1925
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
1926
+ ? msg.payload.taskId
1519
1927
  : null;
1520
1928
  if (taskId) {
1521
1929
  db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
@@ -1866,3 +2274,114 @@ export function getHealth(now: number = Date.now()): HealthReport {
1866
2274
  : "ok";
1867
2275
  return { status, version: VERSION, generatedAt: now, checks };
1868
2276
  }
2277
+
2278
+ // --- Orchestrators ---
2279
+
2280
+ function rowToOrchestrator(row: any): Orchestrator {
2281
+ return {
2282
+ id: row.id,
2283
+ hostname: row.hostname,
2284
+ status: row.status as OrchestratorStatus,
2285
+ agentId: row.agent_id,
2286
+ providers: parseJson<SpawnProvider[]>(row.providers, []),
2287
+ baseDir: row.base_dir,
2288
+ envKeys: parseJson<string[]>(row.env_keys, []),
2289
+ meta: parseJson(row.meta, {}),
2290
+ managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
2291
+ lastSeen: row.last_seen,
2292
+ createdAt: row.created_at,
2293
+ };
2294
+ }
2295
+
2296
+ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
2297
+ const now = Date.now();
2298
+ const agentId = `orchestrator-${input.id}`;
2299
+ const stmt = db.prepare(`
2300
+ INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
2301
+ VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
2302
+ ON CONFLICT(id) DO UPDATE SET
2303
+ hostname = $hostname,
2304
+ status = 'online',
2305
+ providers = $providers,
2306
+ base_dir = $baseDir,
2307
+ env_keys = $envKeys,
2308
+ meta = $meta,
2309
+ last_seen = $now
2310
+ `);
2311
+ stmt.run({
2312
+ $id: input.id,
2313
+ $hostname: input.hostname,
2314
+ $agentId: agentId,
2315
+ $providers: JSON.stringify(input.providers),
2316
+ $baseDir: input.baseDir,
2317
+ $envKeys: JSON.stringify(input.envKeys ?? []),
2318
+ $meta: JSON.stringify(input.meta ?? {}),
2319
+ $now: now,
2320
+ });
2321
+
2322
+ // Also register as an agent so the orchestrator can receive messages
2323
+ upsertAgent({
2324
+ id: agentId,
2325
+ name: `Orchestrator (${input.hostname})`,
2326
+ tags: ["orchestrator", input.hostname],
2327
+ machine: input.hostname,
2328
+ capabilities: ["orchestrator", "spawn"],
2329
+ status: "online",
2330
+ meta: { orchestratorId: input.id, builtin: true },
2331
+ });
2332
+
2333
+ return getOrchestrator(input.id)!;
2334
+ }
2335
+
2336
+ export function getOrchestrator(id: string): Orchestrator | null {
2337
+ const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
2338
+ return row ? rowToOrchestrator(row) : null;
2339
+ }
2340
+
2341
+ export function listOrchestrators(): Orchestrator[] {
2342
+ return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
2343
+ }
2344
+
2345
+ export function orchestratorHeartbeat(id: string): Orchestrator | null {
2346
+ const now = Date.now();
2347
+ db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
2348
+ // Also heartbeat the agent
2349
+ const orch = getOrchestrator(id);
2350
+ if (orch) {
2351
+ heartbeat(orch.agentId);
2352
+ }
2353
+ return orch;
2354
+ }
2355
+
2356
+ export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
2357
+ db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
2358
+ const orch = getOrchestrator(id);
2359
+ if (orch) {
2360
+ setStatus(orch.agentId, status === "online" ? "online" : "offline");
2361
+ }
2362
+ return orch;
2363
+ }
2364
+
2365
+ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
2366
+ db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
2367
+ .run(JSON.stringify(agents), Date.now(), id);
2368
+ return getOrchestrator(id);
2369
+ }
2370
+
2371
+ export function deleteOrchestrator(id: string): boolean {
2372
+ const orch = getOrchestrator(id);
2373
+ if (!orch) return false;
2374
+ db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
2375
+ deleteAgent(orch.agentId);
2376
+ return true;
2377
+ }
2378
+
2379
+ export function reapStaleOrchestrators(): string[] {
2380
+ const cutoff = Date.now() - STALE_TTL_MS;
2381
+ const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
2382
+ for (const row of stale) {
2383
+ db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
2384
+ setStatus(row.agent_id, "offline");
2385
+ }
2386
+ return stale.map((row: any) => row.id);
2387
+ }