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/routes.ts CHANGED
@@ -42,15 +42,35 @@ import {
42
42
  setInboxDraft,
43
43
  deleteInboxDraft,
44
44
  listActivityEvents,
45
+ listChannels,
46
+ listChannelBindings,
47
+ getChannel,
48
+ resolveChannelRoutes,
49
+ upsertChannelBinding,
45
50
  createActivityEvent,
46
51
  createCallbackDelivery,
47
52
  finishCallbackDelivery,
48
53
  reapStaleAgents,
54
+ reapStaleOrchestrators,
49
55
  releaseExpiredClaims,
50
56
  validateAgentSession,
57
+ upsertOrchestrator,
58
+ getOrchestrator,
59
+ listOrchestrators,
60
+ orchestratorHeartbeat,
61
+ updateManagedAgents,
62
+ deleteOrchestrator,
51
63
  ValidationError,
52
64
  } from "./db";
53
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
65
+ import {
66
+ getConnector,
67
+ listConnectors,
68
+ readConnectorConfig,
69
+ registerConnectorManifest,
70
+ runConnectorAction,
71
+ writeConnectorConfig,
72
+ } from "./connectors";
73
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
54
74
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
55
75
  import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
56
76
  import {
@@ -67,6 +87,9 @@ import {
67
87
  emitMessageClaimReleased,
68
88
  emitMessageDeleted,
69
89
  emitTaskChanged,
90
+ emitChannelActivity,
91
+ emitOrchestratorStatus,
92
+ emitOrchestratorRemoved,
70
93
  } from "./sse";
71
94
 
72
95
  type Handler = (
@@ -151,9 +174,13 @@ function parseQueryInt(
151
174
  }
152
175
 
153
176
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
177
+ const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
178
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator"] as const;
179
+ const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
154
180
  const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
155
181
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
156
182
  const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
183
+ const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
157
184
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
158
185
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
159
186
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
@@ -273,6 +300,7 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
273
300
  const input: RegisterAgentInput = {
274
301
  id: cleanString(body.id, "id", { required: true, max: 200 })!,
275
302
  name: cleanString(body.name, "name", { required: true, max: 200 })!,
303
+ kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
276
304
  status: status as RegisterAgentInput["status"] | undefined,
277
305
  ready: body.ready as boolean | undefined,
278
306
  };
@@ -297,9 +325,9 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
297
325
 
298
326
  function normalizeMessageInput(body: unknown): SendMessageInput {
299
327
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
300
- const type = cleanString(body.type, "type", { max: 20 });
301
- if (type && !VALID_MSG_TYPES.includes(type)) {
302
- throw new ValidationError(`type must be one of: ${VALID_MSG_TYPES.join(", ")}`);
328
+ const kind = cleanString(body.kind, "kind", { max: 40 });
329
+ if (kind && !VALID_MSG_KINDS.includes(kind)) {
330
+ throw new ValidationError(`kind must be one of: ${VALID_MSG_KINDS.join(", ")}`);
303
331
  }
304
332
  if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
305
333
  throw new ValidationError("claimable must be a boolean");
@@ -309,7 +337,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
309
337
  from: cleanString(body.from, "from", { required: true, max: 200 })!,
310
338
  to: cleanString(body.to, "to", { required: true, max: 200 })!,
311
339
  body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
312
- type: type as SendMessageInput["type"] | undefined,
340
+ kind: kind as SendMessageInput["kind"] | undefined,
313
341
  replyTo: cleanPositiveId(body.replyTo, "replyTo"),
314
342
  claimable: body.claimable as boolean | undefined,
315
343
  idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
@@ -321,10 +349,168 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
321
349
  if (subject) input.subject = subject;
322
350
  const meta = cleanMeta(body.meta);
323
351
  if (meta) input.meta = meta;
352
+ const payload = cleanMeta(body.payload);
353
+ if (payload) input.payload = payload;
324
354
 
325
355
  return input;
326
356
  }
327
357
 
358
+ function normalizeChannelBindingInput(body: unknown): {
359
+ channelId: string;
360
+ conversationId?: string;
361
+ target: ChannelRouteTarget;
362
+ mode?: ChannelBindingMode;
363
+ priority?: number;
364
+ } {
365
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
366
+ const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
367
+ const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
368
+ const mode = cleanEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
369
+ const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
370
+
371
+ let target: ChannelRouteTarget | undefined;
372
+ if (typeof body.target === "string") {
373
+ target = routeTargetFromAddress(body.target);
374
+ } else if (isRecord(body.target)) {
375
+ const type = cleanEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
376
+ const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
377
+ if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
378
+ target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
379
+ }
380
+ if (!target) throw new ValidationError("target required");
381
+
382
+ return { channelId, conversationId, target, mode, priority };
383
+ }
384
+
385
+ function routeTargetFromAddress(target: string): ChannelRouteTarget {
386
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
387
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
388
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
389
+ if (target === "broadcast") return { type: "broadcast" };
390
+ if (target.startsWith("orchestrator:")) throw new ValidationError("orchestrator channel targets are not supported yet");
391
+ return { type: "agent", id: target };
392
+ }
393
+
394
+ function messageTargetForChannelTarget(target: ChannelRouteTarget): string {
395
+ if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
396
+ if (target.type === "label") return `label:${target.id}`;
397
+ if (target.type === "tag") return `tag:${target.id}`;
398
+ if (target.type === "capability") return `cap:${target.id}`;
399
+ if (target.type === "broadcast") return "broadcast";
400
+ return target.id;
401
+ }
402
+
403
+ function payloadConversationId(payload: Record<string, unknown>): string | undefined {
404
+ const conversation = payload.conversation;
405
+ if (isRecord(conversation)) return cleanString(conversation.id, "payload.conversation.id", { max: 300 });
406
+ return undefined;
407
+ }
408
+
409
+ function payloadDedupeKey(payload: Record<string, unknown>): string | undefined {
410
+ const dedupe = payload.dedupe;
411
+ if (isRecord(dedupe)) return cleanString(dedupe.key, "payload.dedupe.key", { max: 240 });
412
+ return undefined;
413
+ }
414
+
415
+ const EPHEMERAL_CHANNEL_EVENT_TYPES = new Set([
416
+ "typing.started",
417
+ "typing.stopped",
418
+ "message.read",
419
+ "message.delivered",
420
+ "presence.updated",
421
+ ]);
422
+
423
+ function channelPayloadEventType(payload: Record<string, unknown>): string {
424
+ const event = payload.event;
425
+ if (!isRecord(event)) throw new ValidationError("payload.event must be an object");
426
+ return cleanString(event.type, "payload.event.type", { required: true, max: 80 })!;
427
+ }
428
+
429
+ function validateChannelEnvelope(payload: Record<string, unknown>, channel: ChannelSummary): void {
430
+ const schema = cleanString(payload.schema, "payload.schema", { required: true, max: 80 });
431
+ if (schema !== "agent-relay.channel.v1") throw new ValidationError("payload.schema must be agent-relay.channel.v1");
432
+
433
+ const payloadChannel = payload.channel;
434
+ if (!isRecord(payloadChannel)) throw new ValidationError("payload.channel must be an object");
435
+ const provider = cleanString(payloadChannel.provider, "payload.channel.provider", { required: true, max: 80 });
436
+ const accountId = cleanString(payloadChannel.accountId, "payload.channel.accountId", { required: true, max: 200 });
437
+ const agentId = cleanString(payloadChannel.agentId, "payload.channel.agentId", { required: true, max: 200 });
438
+ if (provider !== channel.type || accountId !== channel.accountId || agentId !== channel.agentId) {
439
+ throw new ValidationError("payload.channel does not match channel identity");
440
+ }
441
+
442
+ const direction = cleanString(payload.direction, "payload.direction", { required: true, max: 20 });
443
+ if (direction !== "inbound" && direction !== "outbound") throw new ValidationError("payload.direction must be inbound or outbound");
444
+
445
+ const conversation = payload.conversation;
446
+ if (!isRecord(conversation)) throw new ValidationError("payload.conversation must be an object");
447
+ cleanString(conversation.id, "payload.conversation.id", { required: true, max: 300 });
448
+ cleanString(conversation.type, "payload.conversation.type", { required: true, max: 80 });
449
+
450
+ const actor = payload.actor;
451
+ if (!isRecord(actor)) throw new ValidationError("payload.actor must be an object");
452
+ cleanString(actor.id, "payload.actor.id", { required: true, max: 240 });
453
+ cleanString(actor.kind, "payload.actor.kind", { required: true, max: 80 });
454
+
455
+ channelPayloadEventType(payload);
456
+ validateChannelAttachmentRefs(payload);
457
+ }
458
+
459
+ function validateChannelPayloadContent(payload: Record<string, unknown>, allowActivity: boolean): void {
460
+ const hasMessage = isRecord(payload.message);
461
+ const hasAttachments = Array.isArray(payload.attachments);
462
+ const hasReaction = isRecord(payload.reaction);
463
+ const hasInteraction = isRecord(payload.interaction);
464
+ const hasActivity = isRecord(payload.activity);
465
+ if (!hasMessage && !hasAttachments && !hasReaction && !hasInteraction && !hasActivity) {
466
+ throw new ValidationError("channel payload must include message, attachments, reaction, interaction, or activity");
467
+ }
468
+ if (!allowActivity && hasActivity) throw new ValidationError("channel activity must use /api/channels/:id/activities");
469
+ }
470
+
471
+ function validateChannelAttachmentRefs(payload: Record<string, unknown>): void {
472
+ if (payload.attachments === undefined) return;
473
+ if (!Array.isArray(payload.attachments)) throw new ValidationError("payload.attachments must be an array");
474
+ for (const [index, item] of payload.attachments.entries()) {
475
+ if (!isRecord(item)) throw new ValidationError(`payload.attachments[${index}] must be an object`);
476
+ const ref = item.ref;
477
+ if (!isRecord(ref)) throw new ValidationError(`payload.attachments[${index}].ref must be an object`);
478
+ const type = cleanString(ref.type, `payload.attachments[${index}].ref.type`, { required: true, max: 40 });
479
+ if (type !== "relay-blob" && type !== "external-url" && type !== "channel-file") {
480
+ throw new ValidationError(`payload.attachments[${index}].ref.type must be relay-blob, external-url, or channel-file`);
481
+ }
482
+ if (type === "external-url") cleanString(ref.url, `payload.attachments[${index}].ref.url`, { required: true, max: 2048 });
483
+ if (type === "relay-blob" || type === "channel-file") cleanString(ref.id, `payload.attachments[${index}].ref.id`, { required: true, max: 400 });
484
+ }
485
+ }
486
+
487
+ function normalizeChannelEventBody(body: unknown): {
488
+ body: string;
489
+ payload: Record<string, unknown>;
490
+ conversationId?: string;
491
+ idempotencyKey?: string;
492
+ } {
493
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
494
+ const payload = cleanMeta(body.payload) ?? body;
495
+ const fallbackBody = cleanString(body.body, "body", { max: MAX_BODY_BYTES });
496
+ const text = isRecord(payload.message) ? cleanString(payload.message.text, "payload.message.text", { max: MAX_BODY_BYTES }) : undefined;
497
+ const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 }) ?? payloadConversationId(payload);
498
+ return {
499
+ body: fallbackBody ?? text ?? "Channel event",
500
+ payload,
501
+ conversationId,
502
+ idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }) ?? payloadDedupeKey(payload),
503
+ };
504
+ }
505
+
506
+ function requireChannelSession(req: Request, body: unknown, channel: ChannelSummary): Response | undefined {
507
+ const guard = normalizeAgentSessionGuard(req, body);
508
+ if (!guard || (!guard.instanceId && guard.epoch === undefined)) return error("channel session guard required", 400);
509
+ const session = validateAgentSession(channel.agentId, guard);
510
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
511
+ return undefined;
512
+ }
513
+
328
514
  function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
329
515
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
330
516
  const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
@@ -512,7 +698,8 @@ function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirect
512
698
 
513
699
  function isChannelAgent(agent: AgentCard): boolean {
514
700
  const kind = metaString(agent.meta, "kind");
515
- return kind === "channel" ||
701
+ return agent.kind === "channel" ||
702
+ kind === "channel" ||
516
703
  kind === "communication-channel" ||
517
704
  agent.tags.includes("channel") ||
518
705
  agent.capabilities.includes("channel");
@@ -522,6 +709,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
522
709
  const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
523
710
  const transport = metaString(agent.meta, "transport") ?? type;
524
711
  const topicChannels = metaStringArray(agent.meta, "topicChannels");
712
+ const accountId = metaString(agent.meta, "accountId") ?? (agent.id.startsWith(`${type}:`) ? agent.id.slice(type.length + 1) : agent.id);
525
713
 
526
714
  return {
527
715
  id: metaString(agent.meta, "channelId") ?? agent.id,
@@ -529,6 +717,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
529
717
  type,
530
718
  transport,
531
719
  agentId: agent.id,
720
+ accountId,
532
721
  status: agent.status,
533
722
  ready: agent.ready,
534
723
  direction: metaDirection(agent.meta),
@@ -764,17 +953,19 @@ const postAgentAction: Handler = async (req, params) => {
764
953
  const msg = sendMessage({
765
954
  from: "system",
766
955
  to: agent.id,
767
- type: "system",
956
+ kind: "control",
768
957
  subject: title,
769
958
  body: action === "restart"
770
959
  ? "Dashboard requested that this agent restart its relay-managed session now."
771
960
  : "Dashboard requested that this agent shut down its relay-managed session now.",
772
- meta: {
961
+ payload: {
773
962
  agentControl: {
774
963
  action,
775
964
  requestedBy: "dashboard",
776
965
  requestedAt: Date.now(),
777
966
  },
967
+ },
968
+ meta: {
778
969
  delivery: "interrupt",
779
970
  priority: "urgent",
780
971
  },
@@ -804,11 +995,47 @@ const postAgentSpawn: Handler = async (req) => {
804
995
  if (!parsed.ok) return error(parsed.error, parsed.status);
805
996
  try {
806
997
  if (!isRecord(parsed.body)) return error("provider required");
807
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_AGENT_SPAWN_PROVIDERS);
808
- if (provider !== "codex") return error("provider must be codex");
998
+ const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
999
+ if (!provider) return error("provider required");
809
1000
  const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
810
1001
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
811
1002
  const label = cleanString(parsed.body.label, "label", { max: 120 });
1003
+
1004
+ // Check for an online orchestrator that supports this provider
1005
+ const orchestrators = listOrchestrators().filter(
1006
+ (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
1007
+ );
1008
+ if (orchestrators.length > 0) {
1009
+ // Route through the first available orchestrator
1010
+ const orch = orchestrators[0]!;
1011
+ const msg = sendMessage({
1012
+ from: "system",
1013
+ to: orch.agentId,
1014
+ kind: "control",
1015
+ subject: "Spawn agent",
1016
+ body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1017
+ payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
1018
+ meta: {
1019
+ delivery: "interrupt",
1020
+ priority: "urgent",
1021
+ },
1022
+ });
1023
+ emitNewMessage(msg);
1024
+ auditEvent({
1025
+ clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
1026
+ kind: "state",
1027
+ title: `${provider} agent spawn requested (via ${orch.id})`,
1028
+ body: cwd || orch.baseDir,
1029
+ meta: orch.id,
1030
+ icon: "ti-plus",
1031
+ view: "agents",
1032
+ metadata: { provider, orchestratorId: orch.id, approvalMode },
1033
+ });
1034
+ return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
1035
+ }
1036
+
1037
+ // Fallback: direct spawn for codex only (no orchestrator)
1038
+ if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
812
1039
  const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
813
1040
  const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
814
1041
  const result = spawnCodexAgent({
@@ -822,7 +1049,7 @@ const postAgentSpawn: Handler = async (req) => {
822
1049
  auditEvent({
823
1050
  clientId: "server-agent-spawn-codex-" + Date.now(),
824
1051
  kind: "state",
825
- title: "Codex agent spawn requested",
1052
+ title: "Codex agent spawn requested (direct)",
826
1053
  body: result.cwd,
827
1054
  meta: result.pid ? `pid ${result.pid}` : "dry run",
828
1055
  icon: "ti-plus",
@@ -855,9 +1082,215 @@ const getHostDirectories: Handler = (req) => {
855
1082
  }
856
1083
  };
857
1084
 
1085
+ // --- Orchestrator routes ---
1086
+
1087
+ const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
1088
+ const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
1089
+
1090
+ const postOrchestrator: Handler = async (req) => {
1091
+ const parsed = await parseBody<unknown>(req);
1092
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1093
+ try {
1094
+ if (!isRecord(parsed.body)) return error("body required");
1095
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
1096
+ const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
1097
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
1098
+ const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
1099
+ if (providers) {
1100
+ for (const p of providers) {
1101
+ if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
1102
+ return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
1103
+ }
1104
+ }
1105
+ }
1106
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
1107
+ const meta = cleanMeta(parsed.body.meta);
1108
+ const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
1109
+ auditEvent({
1110
+ clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
1111
+ kind: "state",
1112
+ title: "Orchestrator registered",
1113
+ body: hostname,
1114
+ meta: id,
1115
+ icon: "ti-server-2",
1116
+ view: "orchestrators",
1117
+ metadata: { orchestratorId: id, providers: orch.providers },
1118
+ });
1119
+ return json(orch, 201);
1120
+ } catch (e) {
1121
+ if (e instanceof ValidationError) return error(e.message, 400);
1122
+ throw e;
1123
+ }
1124
+ };
1125
+
1126
+ const getOrchestrators: Handler = () => {
1127
+ return json(listOrchestrators());
1128
+ };
1129
+
1130
+ const getOrchestratorById: Handler = (_req, params) => {
1131
+ const orch = getOrchestrator(params.id!);
1132
+ if (!orch) return error("orchestrator not found", 404);
1133
+ return json(orch);
1134
+ };
1135
+
1136
+ const postOrchestratorHeartbeat: Handler = (_req, params) => {
1137
+ const orch = orchestratorHeartbeat(params.id!);
1138
+ if (!orch) return error("orchestrator not found", 404);
1139
+ return json({ ok: true });
1140
+ };
1141
+
1142
+ const patchOrchestratorAgents: Handler = async (req, params) => {
1143
+ const parsed = await parseBody<unknown>(req);
1144
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1145
+ try {
1146
+ const orch = getOrchestrator(params.id!);
1147
+ if (!orch) return error("orchestrator not found", 404);
1148
+ if (!isRecord(parsed.body)) return error("body required");
1149
+ const agents = parsed.body.agents;
1150
+ if (!Array.isArray(agents)) return error("agents must be an array");
1151
+ const cleaned: ManagedAgent[] = agents.map((a: any) => {
1152
+ if (!isRecord(a)) throw new ValidationError("each agent must be an object");
1153
+ return {
1154
+ agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
1155
+ provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
1156
+ tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
1157
+ cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
1158
+ label: cleanString(a.label, "label", { max: 120 }),
1159
+ approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
1160
+ pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
1161
+ startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
1162
+ };
1163
+ });
1164
+ const updated = updateManagedAgents(params.id!, cleaned);
1165
+ if (updated) emitOrchestratorStatus(params.id!);
1166
+ return json(updated);
1167
+ } catch (e) {
1168
+ if (e instanceof ValidationError) return error(e.message, 400);
1169
+ throw e;
1170
+ }
1171
+ };
1172
+
1173
+ const postOrchestratorSpawn: Handler = async (req, params) => {
1174
+ const parsed = await parseBody<unknown>(req);
1175
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1176
+ try {
1177
+ const orch = getOrchestrator(params.id!);
1178
+ if (!orch) return error("orchestrator not found", 404);
1179
+ if (orch.status !== "online") return error("orchestrator is offline", 409);
1180
+
1181
+ if (!isRecord(parsed.body)) return error("body required");
1182
+ const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
1183
+ if (!orch.providers.includes(provider)) {
1184
+ return error(`orchestrator does not support provider: ${provider}`);
1185
+ }
1186
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1187
+ if (cwd && !cwd.startsWith(orch.baseDir)) {
1188
+ return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
1189
+ }
1190
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
1191
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1192
+ const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1193
+
1194
+ // Send control message to orchestrator's agent inbox
1195
+ const msg = sendMessage({
1196
+ from: "system",
1197
+ to: orch.agentId,
1198
+ kind: "control",
1199
+ subject: "Spawn agent",
1200
+ body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1201
+ payload: {
1202
+ orchestratorControl: {
1203
+ action: "spawn",
1204
+ provider,
1205
+ cwd: cwd || orch.baseDir,
1206
+ label,
1207
+ approvalMode,
1208
+ prompt,
1209
+ requestedBy: "dashboard",
1210
+ requestedAt: Date.now(),
1211
+ },
1212
+ },
1213
+ meta: {
1214
+ delivery: "interrupt",
1215
+ priority: "urgent",
1216
+ },
1217
+ });
1218
+ emitNewMessage(msg);
1219
+ auditEvent({
1220
+ clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
1221
+ kind: "state",
1222
+ title: `Spawn ${provider} agent requested`,
1223
+ body: cwd || orch.baseDir,
1224
+ meta: orch.id,
1225
+ icon: "ti-plus",
1226
+ view: "orchestrators",
1227
+ metadata: { orchestratorId: orch.id, provider, approvalMode, label },
1228
+ });
1229
+ return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
1230
+ } catch (e) {
1231
+ if (e instanceof ValidationError) return error(e.message, 400);
1232
+ throw e;
1233
+ }
1234
+ };
1235
+
1236
+ const postOrchestratorAction: Handler = async (req, params) => {
1237
+ const parsed = await parseBody<unknown>(req);
1238
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1239
+ try {
1240
+ const orch = getOrchestrator(params.id!);
1241
+ if (!orch) return error("orchestrator not found", 404);
1242
+
1243
+ if (!isRecord(parsed.body)) return error("body required");
1244
+ const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
1245
+ if (!action) return error("action required");
1246
+ const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1247
+
1248
+ const msg = sendMessage({
1249
+ from: "system",
1250
+ to: orch.agentId,
1251
+ kind: "control",
1252
+ subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1253
+ body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1254
+ payload: {
1255
+ orchestratorControl: {
1256
+ action,
1257
+ agentId,
1258
+ requestedBy: "dashboard",
1259
+ requestedAt: Date.now(),
1260
+ },
1261
+ },
1262
+ meta: {
1263
+ delivery: "interrupt",
1264
+ priority: "urgent",
1265
+ },
1266
+ });
1267
+ emitNewMessage(msg);
1268
+ auditEvent({
1269
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
1270
+ kind: "state",
1271
+ title: `Agent ${action} requested`,
1272
+ body: agentId || "all",
1273
+ meta: orch.id,
1274
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
1275
+ view: "orchestrators",
1276
+ metadata: { orchestratorId: orch.id, action, agentId },
1277
+ });
1278
+ return json({ ok: true, action, message: msg }, 202);
1279
+ } catch (e) {
1280
+ if (e instanceof ValidationError) return error(e.message, 400);
1281
+ throw e;
1282
+ }
1283
+ };
1284
+
1285
+ const deleteOrchestratorById: Handler = (_req, params) => {
1286
+ const deleted = deleteOrchestrator(params.id!);
1287
+ if (!deleted) return error("orchestrator not found", 404);
1288
+ return json({ ok: true });
1289
+ };
1290
+
858
1291
  // --- Message routes ---
859
1292
 
860
- const VALID_MSG_TYPES = ["message", "system"];
1293
+ const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
861
1294
 
862
1295
  const postMessage: Handler = async (req) => {
863
1296
  const parsed = await parseBody<unknown>(req);
@@ -870,13 +1303,14 @@ const postMessage: Handler = async (req) => {
870
1303
  const result = sendMessageWithResult(input);
871
1304
  if (result.created) {
872
1305
  emitNewMessage(result.message);
1306
+ const isWork = result.message.kind === "task";
873
1307
  auditEvent({
874
1308
  clientId: "server-message-" + result.message.id,
875
- kind: result.message.claimable ? "task" : "message",
876
- title: result.message.claimable ? "Claimable message sent" : "Message sent",
1309
+ kind: isWork ? "task" : "message",
1310
+ title: isWork ? "Task message sent" : "Message sent",
877
1311
  body: result.message.subject || result.message.body,
878
1312
  meta: `${result.message.from} -> ${result.message.to}`,
879
- icon: result.message.claimable ? "ti-hand-grab" : "ti-send",
1313
+ icon: isWork ? "ti-hand-grab" : "ti-send",
880
1314
  view: "messages",
881
1315
  messageId: result.message.id,
882
1316
  agentId: result.message.from,
@@ -898,7 +1332,7 @@ const postSystemBroadcast: Handler = async (req) => {
898
1332
  const msg = sendMessage({
899
1333
  from: "system",
900
1334
  to: "broadcast",
901
- type: "system",
1335
+ kind: "system",
902
1336
  subject: body.subject ?? undefined,
903
1337
  body: body.body,
904
1338
  });
@@ -1127,6 +1561,67 @@ const postActivityEvent: Handler = async (req) => {
1127
1561
  }
1128
1562
  };
1129
1563
 
1564
+ // --- Connectors ---
1565
+
1566
+ const getConnectors: Handler = () => json(listConnectors());
1567
+
1568
+ const getConnectorById: Handler = (_req, params) => {
1569
+ const connector = getConnector(params.id!);
1570
+ return connector ? json(connector) : error("connector not found", 404);
1571
+ };
1572
+
1573
+ const postConnectorRegister: Handler = async (req) => {
1574
+ const parsed = await parseBody<unknown>(req);
1575
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1576
+ try {
1577
+ if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
1578
+ const manifest = isRecord(parsed.body.manifest) ? parsed.body.manifest : parsed.body;
1579
+ const config = isRecord(parsed.body.config) ? parsed.body.config : undefined;
1580
+ const state = isRecord(parsed.body.state) ? parsed.body.state : undefined;
1581
+ return json(registerConnectorManifest(manifest as any, { config, state }), 201);
1582
+ } catch (e) {
1583
+ if (e instanceof ValidationError) return error(e.message, 400);
1584
+ throw e;
1585
+ }
1586
+ };
1587
+
1588
+ const postConnectorAction: Handler = async (req, params) => {
1589
+ const parsed = await parseBody<unknown>(req);
1590
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1591
+ try {
1592
+ if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
1593
+ const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
1594
+ const result = runConnectorAction(params.id!, action);
1595
+ return json(result, result.ok ? 200 : 502);
1596
+ } catch (e) {
1597
+ if (e instanceof ValidationError) return error(e.message, e.message.includes("not found") ? 404 : 400);
1598
+ throw e;
1599
+ }
1600
+ };
1601
+
1602
+ const getConnectorConfig: Handler = (_req, params) => {
1603
+ try {
1604
+ if (!getConnector(params.id!)) return error("connector not found", 404);
1605
+ return json(readConnectorConfig(params.id!));
1606
+ } catch (e) {
1607
+ if (e instanceof ValidationError) return error(e.message, 400);
1608
+ throw e;
1609
+ }
1610
+ };
1611
+
1612
+ const putConnectorConfig: Handler = async (req, params) => {
1613
+ const parsed = await parseBody<unknown>(req);
1614
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1615
+ try {
1616
+ if (!getConnector(params.id!)) return error("connector not found", 404);
1617
+ if (!isRecord(parsed.body)) throw new ValidationError("connector config must be an object");
1618
+ return json(writeConnectorConfig(params.id!, parsed.body));
1619
+ } catch (e) {
1620
+ if (e instanceof ValidationError) return error(e.message, 400);
1621
+ throw e;
1622
+ }
1623
+ };
1624
+
1130
1625
  // --- Tasks and integrations ---
1131
1626
 
1132
1627
  const getIntegrations: Handler = () => {
@@ -1169,16 +1664,104 @@ const getIntegrations: Handler = () => {
1169
1664
  };
1170
1665
 
1171
1666
  const getChannels: Handler = () => {
1172
- const channels = listAgents()
1173
- .filter(isChannelAgent)
1174
- .map(agentToChannel)
1175
- .sort((a, b) => {
1176
- const readyDiff = Number(b.ready) - Number(a.ready);
1177
- if (readyDiff !== 0) return readyDiff;
1178
- return a.name.localeCompare(b.name);
1179
- });
1667
+ return json(listChannels());
1668
+ };
1669
+
1670
+ const getChannelBindings: Handler = (req) => {
1671
+ const url = new URL(req.url);
1672
+ const channelId = cleanString(url.searchParams.get("channelId") ?? undefined, "channelId", { max: 200 });
1673
+ return json(listChannelBindings(channelId));
1674
+ };
1675
+
1676
+ const postChannelBinding: Handler = async (req) => {
1677
+ const parsed = await parseBody<unknown>(req);
1678
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1679
+ try {
1680
+ const input = normalizeChannelBindingInput(parsed.body);
1681
+ const channel = getChannel(input.channelId);
1682
+ if (!channel) return error("channel not found", 404);
1683
+ const sessionError = requireChannelSession(req, parsed.body, channel);
1684
+ if (sessionError) return sessionError;
1685
+ const binding = upsertChannelBinding(input);
1686
+ return json(binding, 201);
1687
+ } catch (e) {
1688
+ if (e instanceof ValidationError) return error(e.message, 400);
1689
+ throw e;
1690
+ }
1691
+ };
1692
+
1693
+ function scopedChannelIdempotencyKey(key: string | undefined, binding: ChannelBinding): string | undefined {
1694
+ return key ? `${key}:${binding.id}` : undefined;
1695
+ }
1696
+
1697
+ const postChannelEvent: Handler = async (req, params) => {
1698
+ const parsed = await parseBody<unknown>(req);
1699
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1700
+ try {
1701
+ const channel = getChannel(params.id!);
1702
+ if (!channel) return error("channel not found", 404);
1703
+ const sessionError = requireChannelSession(req, parsed.body, channel);
1704
+ if (sessionError) return sessionError;
1705
+ const input = normalizeChannelEventBody(parsed.body);
1706
+ validateChannelEnvelope(input.payload, channel);
1707
+ validateChannelPayloadContent(input.payload, false);
1708
+ const eventType = channelPayloadEventType(input.payload);
1709
+ if (EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType)) {
1710
+ return error("ephemeral channel activity must use /api/channels/:id/activities", 400);
1711
+ }
1712
+ const bindings = resolveChannelRoutes(channel.id, input.conversationId);
1713
+ if (!bindings.length) return error("channel has no binding", 409);
1714
+ const results = bindings.map((binding) => sendMessageWithResult({
1715
+ from: channel.agentId,
1716
+ to: messageTargetForChannelTarget(binding.target),
1717
+ kind: "channel.event",
1718
+ channel: channel.id,
1719
+ body: input.body,
1720
+ payload: input.payload,
1721
+ idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
1722
+ claimable: binding.mode === "claimable",
1723
+ }));
1724
+ for (const result of results) {
1725
+ if (result.created) emitNewMessage(result.message);
1726
+ }
1727
+ return json({
1728
+ messages: results.map((result) => result.message),
1729
+ bindings,
1730
+ created: results.some((result) => result.created),
1731
+ }, results.some((result) => result.created) ? 201 : 200);
1732
+ } catch (e) {
1733
+ if (e instanceof ValidationError) return error(e.message, 400);
1734
+ throw e;
1735
+ }
1736
+ };
1180
1737
 
1181
- return json(channels);
1738
+ const postChannelActivity: Handler = async (req, params) => {
1739
+ const parsed = await parseBody<unknown>(req);
1740
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1741
+ try {
1742
+ const channel = getChannel(params.id!);
1743
+ if (!channel) return error("channel not found", 404);
1744
+ const sessionError = requireChannelSession(req, parsed.body, channel);
1745
+ if (sessionError) return sessionError;
1746
+ const input = normalizeChannelEventBody(parsed.body);
1747
+ validateChannelEnvelope(input.payload, channel);
1748
+ validateChannelPayloadContent(input.payload, true);
1749
+ const eventType = channelPayloadEventType(input.payload);
1750
+ if (!EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType) && !input.payload.activity) {
1751
+ return error("channel activity payload must include an ephemeral event type or activity object", 400);
1752
+ }
1753
+ const activity = {
1754
+ channelId: channel.id,
1755
+ conversationId: input.conversationId,
1756
+ payload: input.payload,
1757
+ createdAt: Date.now(),
1758
+ };
1759
+ emitChannelActivity(activity);
1760
+ return json({ ok: true, activity }, 202);
1761
+ } catch (e) {
1762
+ if (e instanceof ValidationError) return error(e.message, 400);
1763
+ throw e;
1764
+ }
1182
1765
  };
1183
1766
 
1184
1767
  const postIntegrationEvent: Handler = async (req) => {
@@ -1486,20 +2069,23 @@ const getHealthRoute: Handler = () => json(getHealth());
1486
2069
  const postSystemReap: Handler = () => {
1487
2070
  const released = releaseExpiredClaims();
1488
2071
  const reapedAgentIds = reapStaleAgents();
2072
+ const reapedOrchestratorIds = reapStaleOrchestrators();
1489
2073
  for (const id of released.messageIds) emitMessageClaimReleased(id);
1490
2074
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
1491
2075
  for (const id of reapedAgentIds) emitAgentStatus(id);
2076
+ for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
1492
2077
  auditEvent({
1493
2078
  clientId: "server-system-reap-" + Date.now(),
1494
2079
  kind: "state",
1495
2080
  title: "Maintenance reaper run",
1496
- body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
2081
+ body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
1497
2082
  icon: "ti-broom",
1498
2083
  view: "activity",
1499
2084
  });
1500
2085
  return json({
1501
2086
  ok: true,
1502
2087
  reapedAgentIds,
2088
+ reapedOrchestratorIds,
1503
2089
  releasedMessageIds: released.messageIds,
1504
2090
  releasedTaskIds: released.tasks.map((task) => task.id),
1505
2091
  });
@@ -1542,6 +2128,15 @@ const routes: Route[] = [
1542
2128
  route("POST", "/api/agents/:id/actions", postAgentAction),
1543
2129
  route("DELETE", "/api/agents/:id", deleteAgentById),
1544
2130
 
2131
+ route("POST", "/api/orchestrators", postOrchestrator),
2132
+ route("GET", "/api/orchestrators", getOrchestrators),
2133
+ route("GET", "/api/orchestrators/:id", getOrchestratorById),
2134
+ route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
2135
+ route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
2136
+ route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
2137
+ route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
2138
+ route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
2139
+
1545
2140
  route("POST", "/api/system/broadcast", postSystemBroadcast),
1546
2141
  route("POST", "/api/messages", postMessage),
1547
2142
  route("GET", "/api/messages", getMessages),
@@ -1561,7 +2156,18 @@ const routes: Route[] = [
1561
2156
  route("GET", "/api/activity", getActivityEvents),
1562
2157
  route("POST", "/api/activity", postActivityEvent),
1563
2158
 
2159
+ route("GET", "/api/connectors", getConnectors),
2160
+ route("POST", "/api/connectors", postConnectorRegister),
2161
+ route("GET", "/api/connectors/:id", getConnectorById),
2162
+ route("POST", "/api/connectors/:id/actions", postConnectorAction),
2163
+ route("GET", "/api/connectors/:id/config", getConnectorConfig),
2164
+ route("PUT", "/api/connectors/:id/config", putConnectorConfig),
2165
+
1564
2166
  route("GET", "/api/channels", getChannels),
2167
+ route("POST", "/api/channels/:id/events", postChannelEvent),
2168
+ route("POST", "/api/channels/:id/activities", postChannelActivity),
2169
+ route("GET", "/api/channel-bindings", getChannelBindings),
2170
+ route("POST", "/api/channel-bindings", postChannelBinding),
1565
2171
  route("GET", "/api/integrations", getIntegrations),
1566
2172
  route("POST", "/api/integrations/events", postIntegrationEvent),
1567
2173
  route("GET", "/api/tasks", getTasks),