agent-relay-server 0.5.0 → 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/index.ts CHANGED
@@ -117,12 +117,36 @@ export function createFetchHandler(
117
117
  return Response.json({ error: "not found" }, { status: 404 });
118
118
  }
119
119
  const file = Bun.file(resolved);
120
- if (await file.exists()) return new Response(file);
120
+ if (await file.exists()) {
121
+ const headers = staticHeaders(requested);
122
+ return new Response(file, { headers });
123
+ }
121
124
 
122
125
  return Response.json({ error: "not found" }, { status: 404 });
123
126
  };
124
127
  }
125
128
 
129
+ function staticHeaders(pathname: string): Headers {
130
+ const headers = new Headers();
131
+ const contentType = staticContentType(pathname);
132
+ if (contentType) headers.set("Content-Type", contentType);
133
+ if (pathname === "/sw.js") {
134
+ headers.set("Cache-Control", "no-cache");
135
+ } else if (pathname === "/manifest.webmanifest") {
136
+ headers.set("Cache-Control", "no-cache");
137
+ }
138
+ return headers;
139
+ }
140
+
141
+ function staticContentType(pathname: string): string | undefined {
142
+ if (pathname.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
143
+ if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
144
+ if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
145
+ if (pathname.endsWith(".svg")) return "image/svg+xml";
146
+ if (pathname.endsWith(".png")) return "image/png";
147
+ return undefined;
148
+ }
149
+
126
150
  if (import.meta.main) {
127
151
  main().catch((error) => {
128
152
  console.error(error instanceof Error ? error.message : String(error));
package/src/routes.ts CHANGED
@@ -42,6 +42,11 @@ 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,
@@ -57,7 +62,15 @@ import {
57
62
  deleteOrchestrator,
58
63
  ValidationError,
59
64
  } from "./db";
60
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, 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";
61
74
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
62
75
  import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
63
76
  import {
@@ -74,6 +87,7 @@ import {
74
87
  emitMessageClaimReleased,
75
88
  emitMessageDeleted,
76
89
  emitTaskChanged,
90
+ emitChannelActivity,
77
91
  emitOrchestratorStatus,
78
92
  emitOrchestratorRemoved,
79
93
  } from "./sse";
@@ -160,9 +174,13 @@ function parseQueryInt(
160
174
  }
161
175
 
162
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;
163
180
  const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
164
181
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
165
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;
166
184
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
167
185
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
168
186
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
@@ -282,6 +300,7 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
282
300
  const input: RegisterAgentInput = {
283
301
  id: cleanString(body.id, "id", { required: true, max: 200 })!,
284
302
  name: cleanString(body.name, "name", { required: true, max: 200 })!,
303
+ kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
285
304
  status: status as RegisterAgentInput["status"] | undefined,
286
305
  ready: body.ready as boolean | undefined,
287
306
  };
@@ -306,9 +325,9 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
306
325
 
307
326
  function normalizeMessageInput(body: unknown): SendMessageInput {
308
327
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
309
- const type = cleanString(body.type, "type", { max: 20 });
310
- if (type && !VALID_MSG_TYPES.includes(type)) {
311
- 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(", ")}`);
312
331
  }
313
332
  if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
314
333
  throw new ValidationError("claimable must be a boolean");
@@ -318,7 +337,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
318
337
  from: cleanString(body.from, "from", { required: true, max: 200 })!,
319
338
  to: cleanString(body.to, "to", { required: true, max: 200 })!,
320
339
  body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
321
- type: type as SendMessageInput["type"] | undefined,
340
+ kind: kind as SendMessageInput["kind"] | undefined,
322
341
  replyTo: cleanPositiveId(body.replyTo, "replyTo"),
323
342
  claimable: body.claimable as boolean | undefined,
324
343
  idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
@@ -330,10 +349,168 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
330
349
  if (subject) input.subject = subject;
331
350
  const meta = cleanMeta(body.meta);
332
351
  if (meta) input.meta = meta;
352
+ const payload = cleanMeta(body.payload);
353
+ if (payload) input.payload = payload;
333
354
 
334
355
  return input;
335
356
  }
336
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
+
337
514
  function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
338
515
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
339
516
  const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
@@ -521,7 +698,8 @@ function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirect
521
698
 
522
699
  function isChannelAgent(agent: AgentCard): boolean {
523
700
  const kind = metaString(agent.meta, "kind");
524
- return kind === "channel" ||
701
+ return agent.kind === "channel" ||
702
+ kind === "channel" ||
525
703
  kind === "communication-channel" ||
526
704
  agent.tags.includes("channel") ||
527
705
  agent.capabilities.includes("channel");
@@ -531,6 +709,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
531
709
  const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
532
710
  const transport = metaString(agent.meta, "transport") ?? type;
533
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);
534
713
 
535
714
  return {
536
715
  id: metaString(agent.meta, "channelId") ?? agent.id,
@@ -538,6 +717,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
538
717
  type,
539
718
  transport,
540
719
  agentId: agent.id,
720
+ accountId,
541
721
  status: agent.status,
542
722
  ready: agent.ready,
543
723
  direction: metaDirection(agent.meta),
@@ -773,17 +953,19 @@ const postAgentAction: Handler = async (req, params) => {
773
953
  const msg = sendMessage({
774
954
  from: "system",
775
955
  to: agent.id,
776
- type: "system",
956
+ kind: "control",
777
957
  subject: title,
778
958
  body: action === "restart"
779
959
  ? "Dashboard requested that this agent restart its relay-managed session now."
780
960
  : "Dashboard requested that this agent shut down its relay-managed session now.",
781
- meta: {
961
+ payload: {
782
962
  agentControl: {
783
963
  action,
784
964
  requestedBy: "dashboard",
785
965
  requestedAt: Date.now(),
786
966
  },
967
+ },
968
+ meta: {
787
969
  delivery: "interrupt",
788
970
  priority: "urgent",
789
971
  },
@@ -829,11 +1011,11 @@ const postAgentSpawn: Handler = async (req) => {
829
1011
  const msg = sendMessage({
830
1012
  from: "system",
831
1013
  to: orch.agentId,
832
- type: "system",
1014
+ kind: "control",
833
1015
  subject: "Spawn agent",
834
- body: JSON.stringify({ action: "spawn", provider, cwd, label, approvalMode }),
1016
+ body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1017
+ payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
835
1018
  meta: {
836
- orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
837
1019
  delivery: "interrupt",
838
1020
  priority: "urgent",
839
1021
  },
@@ -969,7 +1151,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
969
1151
  const cleaned: ManagedAgent[] = agents.map((a: any) => {
970
1152
  if (!isRecord(a)) throw new ValidationError("each agent must be an object");
971
1153
  return {
972
- agentId: cleanString(a.agentId, "agentId", { required: true, max: 240 })!,
1154
+ agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
973
1155
  provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
974
1156
  tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
975
1157
  cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
@@ -980,6 +1162,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
980
1162
  };
981
1163
  });
982
1164
  const updated = updateManagedAgents(params.id!, cleaned);
1165
+ if (updated) emitOrchestratorStatus(params.id!);
983
1166
  return json(updated);
984
1167
  } catch (e) {
985
1168
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -1012,10 +1195,10 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1012
1195
  const msg = sendMessage({
1013
1196
  from: "system",
1014
1197
  to: orch.agentId,
1015
- type: "system",
1198
+ kind: "control",
1016
1199
  subject: "Spawn agent",
1017
- body: JSON.stringify({ action: "spawn", provider, cwd: cwd || orch.baseDir, label, approvalMode, prompt }),
1018
- meta: {
1200
+ body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1201
+ payload: {
1019
1202
  orchestratorControl: {
1020
1203
  action: "spawn",
1021
1204
  provider,
@@ -1026,6 +1209,8 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1026
1209
  requestedBy: "dashboard",
1027
1210
  requestedAt: Date.now(),
1028
1211
  },
1212
+ },
1213
+ meta: {
1029
1214
  delivery: "interrupt",
1030
1215
  priority: "urgent",
1031
1216
  },
@@ -1063,16 +1248,18 @@ const postOrchestratorAction: Handler = async (req, params) => {
1063
1248
  const msg = sendMessage({
1064
1249
  from: "system",
1065
1250
  to: orch.agentId,
1066
- type: "system",
1251
+ kind: "control",
1067
1252
  subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1068
- body: JSON.stringify({ action, agentId }),
1069
- meta: {
1253
+ body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1254
+ payload: {
1070
1255
  orchestratorControl: {
1071
1256
  action,
1072
1257
  agentId,
1073
1258
  requestedBy: "dashboard",
1074
1259
  requestedAt: Date.now(),
1075
1260
  },
1261
+ },
1262
+ meta: {
1076
1263
  delivery: "interrupt",
1077
1264
  priority: "urgent",
1078
1265
  },
@@ -1103,7 +1290,7 @@ const deleteOrchestratorById: Handler = (_req, params) => {
1103
1290
 
1104
1291
  // --- Message routes ---
1105
1292
 
1106
- const VALID_MSG_TYPES = ["message", "system"];
1293
+ const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
1107
1294
 
1108
1295
  const postMessage: Handler = async (req) => {
1109
1296
  const parsed = await parseBody<unknown>(req);
@@ -1116,13 +1303,14 @@ const postMessage: Handler = async (req) => {
1116
1303
  const result = sendMessageWithResult(input);
1117
1304
  if (result.created) {
1118
1305
  emitNewMessage(result.message);
1306
+ const isWork = result.message.kind === "task";
1119
1307
  auditEvent({
1120
1308
  clientId: "server-message-" + result.message.id,
1121
- kind: result.message.claimable ? "task" : "message",
1122
- title: result.message.claimable ? "Claimable message sent" : "Message sent",
1309
+ kind: isWork ? "task" : "message",
1310
+ title: isWork ? "Task message sent" : "Message sent",
1123
1311
  body: result.message.subject || result.message.body,
1124
1312
  meta: `${result.message.from} -> ${result.message.to}`,
1125
- icon: result.message.claimable ? "ti-hand-grab" : "ti-send",
1313
+ icon: isWork ? "ti-hand-grab" : "ti-send",
1126
1314
  view: "messages",
1127
1315
  messageId: result.message.id,
1128
1316
  agentId: result.message.from,
@@ -1144,7 +1332,7 @@ const postSystemBroadcast: Handler = async (req) => {
1144
1332
  const msg = sendMessage({
1145
1333
  from: "system",
1146
1334
  to: "broadcast",
1147
- type: "system",
1335
+ kind: "system",
1148
1336
  subject: body.subject ?? undefined,
1149
1337
  body: body.body,
1150
1338
  });
@@ -1373,6 +1561,67 @@ const postActivityEvent: Handler = async (req) => {
1373
1561
  }
1374
1562
  };
1375
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
+
1376
1625
  // --- Tasks and integrations ---
1377
1626
 
1378
1627
  const getIntegrations: Handler = () => {
@@ -1415,16 +1664,104 @@ const getIntegrations: Handler = () => {
1415
1664
  };
1416
1665
 
1417
1666
  const getChannels: Handler = () => {
1418
- const channels = listAgents()
1419
- .filter(isChannelAgent)
1420
- .map(agentToChannel)
1421
- .sort((a, b) => {
1422
- const readyDiff = Number(b.ready) - Number(a.ready);
1423
- if (readyDiff !== 0) return readyDiff;
1424
- return a.name.localeCompare(b.name);
1425
- });
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
+ }
1426
1696
 
1427
- return json(channels);
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
+ };
1737
+
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
+ }
1428
1765
  };
1429
1766
 
1430
1767
  const postIntegrationEvent: Handler = async (req) => {
@@ -1819,7 +2156,18 @@ const routes: Route[] = [
1819
2156
  route("GET", "/api/activity", getActivityEvents),
1820
2157
  route("POST", "/api/activity", postActivityEvent),
1821
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
+
1822
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),
1823
2171
  route("GET", "/api/integrations", getIntegrations),
1824
2172
  route("POST", "/api/integrations/events", postIntegrationEvent),
1825
2173
  route("GET", "/api/tasks", getTasks),
package/src/security.ts CHANGED
@@ -95,7 +95,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
95
95
  if (pathname === "/api/stats") return "stats:read";
96
96
  if (pathname === "/api/health") return "health:read";
97
97
  if (pathname === "/api/events") return "events:read";
98
- if (pathname === "/api/channels") return "channels:read";
98
+ if (pathname === "/api/connectors" || pathname.startsWith("/api/connectors/")) return method === "GET" ? "connectors:read" : "system:write";
99
+ if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channels:read" : "channels:write";
99
100
  if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
100
101
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
101
102
  if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
package/src/sse.ts CHANGED
@@ -122,6 +122,12 @@ export function emitTaskChanged(task: Task, eventType = "task.updated") {
122
122
  }
123
123
  }
124
124
 
125
+ export function emitChannelActivity(activity: Record<string, unknown>) {
126
+ for (const conn of connections.values()) {
127
+ send(conn, "channel.activity", activity);
128
+ }
129
+ }
130
+
125
131
  export function getConnectionCount(): number {
126
132
  return connections.size;
127
133
  }