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/README.md CHANGED
@@ -40,20 +40,20 @@ Agent Relay has two parts:
40
40
 
41
41
  Pick your provider: install the Claude Code plugin, the Codex connector, or both. The shared config is the same four env vars either way. See the [full mental model](docs/mental-model.md) for routing, identity, and task lifecycle details, or the [provider spec](docs/provider-spec.md) if you want to build your own integration.
42
42
 
43
- ## Extension Model
43
+ ## Connector Model
44
44
 
45
- Agent Relay is built around four extension points:
45
+ Agent Relay extensions run as external **Connectors**. The relay discovers them from `~/.agent-relay/connectors/<id>/manifest.json`, then can show status and run lifecycle actions without loading connector code in-process.
46
46
 
47
47
  > **Agents do work. Providers host agents. Integrations create work. Channels carry conversations.**
48
48
 
49
- That split keeps the system small without making everything a one-off plugin:
49
+ Connector kinds:
50
50
 
51
- - **Agents** are running AI sessions or workers that can receive messages, claim tasks, and produce results.
52
- - **Providers** connect an AI runtime to the relay. The Claude plugin and Codex connector are providers.
53
- - **Integrations** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
54
- - **Channels** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
51
+ - **Provider connectors** host AI runtimes. The Claude plugin and Codex connector register coding-agent sessions as Relay agents.
52
+ - **Event connectors** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
53
+ - **Channel connectors** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
54
+ - **Orchestrator connectors** supervise hosts and can spawn or control provider sessions.
55
55
 
56
- If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider. Sending alerts or tickets into Agent Relay? Build an integration. Opening Agent Relay to another messaging app? Build a channel.
56
+ If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider connector. Sending alerts or tickets into Agent Relay? Build an event connector. Opening Agent Relay to another messaging app? Build a channel connector.
57
57
 
58
58
  ## Quick Start
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -60,6 +60,7 @@
60
60
  tasks: [],
61
61
  integrations: [],
62
62
  channels: [],
63
+ connectors: [],
63
64
  taskEvents: [],
64
65
  taskEventCache: {},
65
66
  stats: {},
@@ -240,6 +241,7 @@
240
241
  if (view === "work") await Promise.all([this.fetchMessages(), this.fetchTasks()]);
241
242
  if (view === "pairs") this.fetchPairs();
242
243
  if (view === "channels") await this.fetchChannels();
244
+ if (view === "connectors") await this.fetchConnectors();
243
245
  if (view === "integrations") await this.fetchIntegrations();
244
246
  if (view === "tasks") this.fetchTasks();
245
247
  },
@@ -393,7 +395,7 @@
393
395
  },
394
396
 
395
397
  async refresh() {
396
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
398
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchConnectors(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
397
399
  },
398
400
 
399
401
  async refreshLiveData() {
@@ -476,6 +478,12 @@
476
478
  } catch {}
477
479
  },
478
480
 
481
+ async fetchConnectors() {
482
+ try {
483
+ this.connectors = await this.api("GET", "/connectors");
484
+ } catch {}
485
+ },
486
+
479
487
  async fetchChannels() {
480
488
  try {
481
489
  this.channels = await this.api("GET", "/channels");
@@ -548,6 +556,7 @@
548
556
  activityItems: { get: getActivityItems },
549
557
  workQueueItems: { get: getWorkQueueItems },
550
558
  channelCards: { get: getChannelCards },
559
+ connectorCards: { get: getConnectorCards },
551
560
  integrationCards: { get: getIntegrationCards },
552
561
  openPairCount: { get: getOpenPairCount },
553
562
  filteredMessages: { get: getFilteredMessages },
@@ -644,6 +653,11 @@
644
653
  if (!message || !channel) return false;
645
654
  const channelKeys = [channel.id, channel.type, ...(channel.topicChannels || [])].filter(Boolean);
646
655
  if (message.channel && channelKeys.includes(message.channel)) return true;
656
+ const payloadChannel = message.payload?.channel;
657
+ if (payloadChannel && typeof payloadChannel === "object") {
658
+ const payloadKeys = [payloadChannel.agentId, payloadChannel.provider, payloadChannel.accountId].filter(Boolean);
659
+ if (payloadKeys.includes(channel.id) || payloadKeys.includes(channel.type) || payloadKeys.includes(channel.accountId)) return true;
660
+ }
647
661
  if (channel.agentId && (message.from === channel.agentId || message.to === channel.agentId)) return true;
648
662
  return false;
649
663
  }
@@ -710,7 +724,7 @@
710
724
  const haystack = [
711
725
  vm.displayTarget(thread.peer),
712
726
  thread.peer,
713
- ...thread.messages.flatMap((msg) => [msg.subject || "", msg.body || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
727
+ ...thread.messages.flatMap((msg) => [msg.subject || "", messageBody.call(vm, msg) || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
714
728
  ].join("\n").toLowerCase();
715
729
  return haystack.includes(search);
716
730
  }
@@ -799,12 +813,12 @@
799
813
  }));
800
814
 
801
815
  const messageItems = (this.messages || [])
802
- .filter((msg) => msg.claimable)
816
+ .filter(isClaimableMessageWaiting)
803
817
  .map((msg) => ({
804
818
  id: "message-" + msg.id,
805
819
  sourceType: "message",
806
820
  title: msg.subject || "Claimable message #" + msg.id,
807
- body: msg.body,
821
+ body: messageBody(msg),
808
822
  severity: "warning",
809
823
  status: msg.claimedBy ? "claimed" : "open",
810
824
  owner: msg.claimedBy || "",
@@ -852,7 +866,7 @@
852
866
  return (vm.messages || []).flatMap((msg) => {
853
867
  const ts = toTimestamp(msg.createdAt);
854
868
  const items = [];
855
- const pairEvent = msg.meta?.pairEvent;
869
+ const pairEvent = msg.payload?.pairEvent;
856
870
  if (pairEvent) {
857
871
  items.push(activityItem({
858
872
  id: "pair-message-" + msg.id,
@@ -868,10 +882,10 @@
868
882
  } else if (msg.from === HUMAN_AGENT_ID) {
869
883
  items.push(activityItem({
870
884
  id: "human-send-" + msg.id,
871
- kind: msg.claimable ? "task" : "message",
885
+ kind: msg.kind === "task" ? "task" : "message",
872
886
  ts,
873
- icon: msg.claimable ? "ti-hand-grab" : "ti-send",
874
- title: msg.claimable ? "Claimable task sent" : "Message sent",
887
+ icon: msg.kind === "task" ? "ti-hand-grab" : "ti-send",
888
+ title: msg.kind === "task" ? "Claimable task sent" : "Message sent",
875
889
  body: vm.messagePreview(msg),
876
890
  meta: "to " + vm.displayTarget(msg.to),
877
891
  view: inboxPeer(msg) ? "inbox" : "messages",
@@ -1049,7 +1063,7 @@
1049
1063
  }
1050
1064
 
1051
1065
  function messageLooksLikeQuestion(msg) {
1052
- return /\?/.test(`${msg.subject || ""}\n${msg.body || ""}`);
1066
+ return /\?/.test(`${msg.subject || ""}\n${messageBody(msg)}`);
1053
1067
  }
1054
1068
 
1055
1069
  function countClaimableWaiting(vm) {
@@ -1069,7 +1083,7 @@
1069
1083
  }
1070
1084
 
1071
1085
  function isClaimableMessageWaiting(msg) {
1072
- return Boolean(msg.claimable && !msg.claimedBy);
1086
+ return Boolean(msg.claimable && !msg.claimedBy && !(msg.kind === "task" && Number.isSafeInteger(msg.payload?.taskId)));
1073
1087
  }
1074
1088
 
1075
1089
  function targetMatchesAgent(target, agent) {
@@ -1141,6 +1155,17 @@
1141
1155
  });
1142
1156
  }
1143
1157
 
1158
+ function getConnectorCards() {
1159
+ return [...(this.connectors || [])].sort((a, b) => {
1160
+ const statusRank = { error: 0, warn: 1, unknown: 2, ok: 3 };
1161
+ const aStatus = a.runtime?.status || "unknown";
1162
+ const bStatus = b.runtime?.status || "unknown";
1163
+ const statusDiff = (statusRank[aStatus] ?? 2) - (statusRank[bStatus] ?? 2);
1164
+ if (statusDiff !== 0) return statusDiff;
1165
+ return String(a.displayName || a.id || "").localeCompare(String(b.displayName || b.id || ""));
1166
+ });
1167
+ }
1168
+
1144
1169
  function getOpenPairCount() {
1145
1170
  return (this.pairs || []).filter(isOpenPair).length;
1146
1171
  }
@@ -1204,7 +1229,7 @@
1204
1229
  }
1205
1230
 
1206
1231
  function isChannelAgent(agent) {
1207
- return agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
1232
+ return agent?.kind === "channel" || agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
1208
1233
  }
1209
1234
 
1210
1235
  function agentSupportsControlActions(agent) {
@@ -1343,6 +1368,7 @@
1343
1368
  displayName,
1344
1369
  displayTarget,
1345
1370
  conversationTitle,
1371
+ messageBody,
1346
1372
  messagePreview,
1347
1373
  agentPair,
1348
1374
  pairPeerId,
@@ -1363,6 +1389,7 @@
1363
1389
  isBuiltInAgent,
1364
1390
  agentChannels,
1365
1391
  channelPresence,
1392
+ connectorPresence,
1366
1393
  integrationPresence,
1367
1394
  composeTargetAllowsClaimable,
1368
1395
  inboxComposeTargetAllowsClaimable,
@@ -1396,10 +1423,43 @@
1396
1423
  }
1397
1424
 
1398
1425
  function messagePreview(msg) {
1399
- const text = msg?.subject || msg?.body || "";
1426
+ const text = msg?.subject || messageBody(msg) || "";
1400
1427
  return text.length > 90 ? text.slice(0, 90) + "..." : text;
1401
1428
  }
1402
1429
 
1430
+ function messageBody(msg) {
1431
+ if (!msg) return "";
1432
+ const payload = msg.payload || {};
1433
+ const channelMessage = payload.message;
1434
+ if (channelMessage && typeof channelMessage === "object" && typeof channelMessage.text === "string" && channelMessage.text.trim()) {
1435
+ return channelMessage.text;
1436
+ }
1437
+ const interaction = payload.interaction;
1438
+ if (interaction && typeof interaction === "object") {
1439
+ const title = typeof interaction.title === "string" ? interaction.title.trim() : "";
1440
+ const description = typeof interaction.description === "string" ? interaction.description.trim() : "";
1441
+ if (title && description) return title + "\n" + description;
1442
+ if (title) return title;
1443
+ if (description) return description;
1444
+ }
1445
+ const reaction = payload.reaction;
1446
+ if (reaction && typeof reaction === "object") {
1447
+ const name = typeof reaction.name === "string" ? reaction.name : "";
1448
+ const emoji = typeof reaction.emoji === "string" ? reaction.emoji : "";
1449
+ const value = typeof reaction.value === "string" ? reaction.value : "";
1450
+ return ["Reaction", emoji || name || value].filter(Boolean).join(": ");
1451
+ }
1452
+ const activity = payload.activity;
1453
+ if (activity && typeof activity === "object") {
1454
+ const kind = typeof activity.kind === "string" ? activity.kind : "activity";
1455
+ const state = typeof activity.state === "string" ? activity.state : "";
1456
+ return [kind, state].filter(Boolean).join(" ");
1457
+ }
1458
+ if (typeof payload.text === "string" && payload.text.trim()) return payload.text;
1459
+ if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
1460
+ return msg.body || "";
1461
+ }
1462
+
1403
1463
  function agentPair(agent) {
1404
1464
  return agent ? this.pairsByAgentId[agent.id] : null;
1405
1465
  }
@@ -1583,6 +1643,16 @@
1583
1643
  return { label: "configured", tone: "primary", icon: "ti-plug-connected" };
1584
1644
  }
1585
1645
 
1646
+ function connectorPresence(connector) {
1647
+ const runtime = connector?.runtime || {};
1648
+ if (runtime.status === "error") return { label: "error", tone: "danger", icon: "ti-alert-triangle" };
1649
+ if (runtime.status === "warn") return { label: "warning", tone: "warning", icon: "ti-alert-circle" };
1650
+ if (runtime.running) return { label: "running", tone: "success", icon: "ti-circle-check" };
1651
+ if (runtime.enabled === false) return { label: "disabled", tone: "secondary", icon: "ti-player-pause" };
1652
+ if (runtime.status === "ok") return { label: "ok", tone: "success", icon: "ti-circle-check" };
1653
+ return { label: "unknown", tone: "secondary", icon: "ti-help-circle" };
1654
+ }
1655
+
1586
1656
  function agentChannels(agent) {
1587
1657
  if (!agent) return [];
1588
1658
  return (this.channels || []).filter((ch) => ch.agentId === agent.id);
@@ -1675,6 +1745,7 @@
1675
1745
  recordOperatorActivity,
1676
1746
  openActivityItem,
1677
1747
  runHealthAction,
1748
+ runConnectorAction,
1678
1749
  openCommandPalette,
1679
1750
  closeCommandPalette,
1680
1751
  runCommand,
@@ -1940,7 +2011,11 @@
1940
2011
  };
1941
2012
  if (this.inboxCompose.channel) payload.channel = this.inboxCompose.channel;
1942
2013
  if (this.inboxCompose.subject) payload.subject = this.inboxCompose.subject;
1943
- if (this.inboxCompose.claimable && inboxComposeTargetAllowsClaimable.call(this)) payload.claimable = true;
2014
+ if (this.inboxCompose.claimable && inboxComposeTargetAllowsClaimable.call(this)) {
2015
+ payload.claimable = true;
2016
+ payload.kind = "task";
2017
+ payload.payload = { title: this.inboxCompose.subject || "Claimable task" };
2018
+ }
1944
2019
  await this.api("POST", "/messages", payload);
1945
2020
  this.recordOperatorActivity({
1946
2021
  title: payload.claimable ? "Claimable task sent" : "Message sent",
@@ -1983,7 +2058,11 @@
1983
2058
  if (vm.compose.channel) payload.channel = vm.compose.channel;
1984
2059
  if (vm.compose.subject) payload.subject = vm.compose.subject;
1985
2060
  if (vm.replyTo) payload.replyTo = vm.replyTo.id;
1986
- if (vm.compose.claimable && composeTargetAllowsClaimable.call(vm)) payload.claimable = true;
2061
+ if (vm.compose.claimable && composeTargetAllowsClaimable.call(vm)) {
2062
+ payload.claimable = true;
2063
+ payload.kind = "task";
2064
+ payload.payload = { title: vm.compose.subject || "Claimable task" };
2065
+ }
1987
2066
  return payload;
1988
2067
  }
1989
2068
 
@@ -2200,6 +2279,16 @@
2200
2279
  if (action.copy) await copyText(action.copy);
2201
2280
  }
2202
2281
 
2282
+ async function runConnectorAction(connector, action) {
2283
+ if (!connector || !action) return;
2284
+ try {
2285
+ await this.api("POST", "/connectors/" + encodeURIComponent(connector.id) + "/actions", { action });
2286
+ await this.fetchConnectors();
2287
+ } catch (e) {
2288
+ alert("Connector action failed: " + e.message);
2289
+ }
2290
+ }
2291
+
2203
2292
  async function copyText(value) {
2204
2293
  if (typeof navigator === "undefined") return;
2205
2294
  try {
@@ -2289,8 +2378,8 @@
2289
2378
 
2290
2379
  function pairMessages(vm, pair) {
2291
2380
  return (vm.messages || []).filter((msg) =>
2292
- msg.meta?.pairId === pair.id ||
2293
- (msg.meta?.pairEvent && [pair.requesterId, pair.targetId].includes(msg.from) && [pair.requesterId, pair.targetId].includes(msg.to))
2381
+ msg.payload?.pairId === pair.id ||
2382
+ (msg.payload?.pairEvent && [pair.requesterId, pair.targetId].includes(msg.from) && [pair.requesterId, pair.targetId].includes(msg.to))
2294
2383
  );
2295
2384
  }
2296
2385
 
@@ -2343,7 +2432,7 @@
2343
2432
  if (msg.channel) lines.push("- Channel: " + msg.channel);
2344
2433
  if (msg.subject) lines.push("- Subject: " + msg.subject);
2345
2434
  if (msg.claimedBy) lines.push("- Claimed by: " + vm.displayTarget(msg.claimedBy));
2346
- lines.push("", msg.body || "", "");
2435
+ lines.push("", messageBody.call(vm, msg) || "", "");
2347
2436
  }
2348
2437
  }
2349
2438
 
Binary file
Binary file
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="96" fill="#0d1117"/>
3
+ <g stroke="#30363d" stroke-width="22" stroke-linecap="round">
4
+ <line x1="137" y1="151" x2="221" y2="229"/>
5
+ <line x1="375" y1="151" x2="291" y2="229"/>
6
+ <line x1="137" y1="361" x2="221" y2="283"/>
7
+ <line x1="375" y1="361" x2="291" y2="283"/>
8
+ </g>
9
+ <circle cx="256" cy="256" r="72" fill="#58a6ff"/>
10
+ <circle cx="96" cy="116" r="42" fill="#3fb950"/>
11
+ <circle cx="416" cy="116" r="42" fill="#3fb950"/>
12
+ <circle cx="96" cy="396" r="42" fill="#3fb950"/>
13
+ <circle cx="416" cy="396" r="42" fill="#3fb950"/>
14
+ </svg>
package/public/index.html CHANGED
@@ -3,8 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="apple-mobile-web-app-title" content="Agent Relay">
10
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
6
11
  <title>Agent Relay</title>
7
12
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230d1117'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
13
+ <link rel="manifest" href="/manifest.webmanifest">
14
+ <link rel="apple-touch-icon" href="/icons/agent-relay-192.png">
8
15
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css">
9
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
10
17
  <style>
@@ -220,6 +227,10 @@
220
227
  <i class="ti ti-messages"></i>Channels
221
228
  <span class="badge bg-success text-white ms-auto" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length"></span>
222
229
  </a>
230
+ <a href="#" class="nav-link" :class="{ active: view === 'connectors' }" @click.prevent="switchView('connectors')">
231
+ <i class="ti ti-plug"></i>Connectors
232
+ <span class="badge bg-secondary text-white ms-auto" x-show="connectorCards.length > 0" x-text="connectorCards.length"></span>
233
+ </a>
223
234
  <a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
224
235
  <i class="ti ti-plug-connected"></i>Integrations
225
236
  <span class="ms-auto d-inline-flex gap-1 align-items-center">
@@ -292,7 +303,7 @@
292
303
 
293
304
  <!-- Mobile nav -->
294
305
  <div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
295
- <template x-for="v in ['overview','agents','channels','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
306
+ <template x-for="v in ['overview','agents','channels','connectors','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
296
307
  <button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
297
308
  </template>
298
309
  </div>
@@ -911,6 +922,75 @@ agent-relay-orchestrator</pre>
911
922
  </template>
912
923
  </div>
913
924
 
925
+ <!-- ==================== CONNECTORS ==================== -->
926
+ <div x-show="view === 'connectors'" x-cloak class="fade-in">
927
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
928
+ <h2 class="page-title mb-0">Connectors</h2>
929
+ <span class="badge bg-secondary-lt" x-show="connectorCards.length > 0" x-text="connectorCards.length + ' registered'"></span>
930
+ <div class="ms-auto">
931
+ <button class="btn btn-sm btn-ghost-secondary" @click="fetchConnectors()" title="Refresh">
932
+ <i class="ti ti-refresh"></i>
933
+ </button>
934
+ </div>
935
+ </div>
936
+
937
+ <div class="row g-3">
938
+ <template x-for="connector in connectorCards" :key="connector.id">
939
+ <div class="col-md-6 col-xl-4">
940
+ <div class="card">
941
+ <div class="card-body">
942
+ <div class="d-flex align-items-start gap-2">
943
+ <span class="agent-type-icon agent mt-0">
944
+ <i class="ti" :class="connectorPresence(connector).icon"></i>
945
+ </span>
946
+ <div class="flex-grow-1 min-width-0">
947
+ <div class="d-flex align-items-center gap-2 flex-wrap">
948
+ <span class="fw-bold text-truncate" x-text="connector.displayName || connector.id"></span>
949
+ <span class="badge" :class="'bg-' + connectorPresence(connector).tone + '-lt'">
950
+ <i class="ti me-1" :class="connectorPresence(connector).icon"></i><span x-text="connectorPresence(connector).label"></span>
951
+ </span>
952
+ <span class="badge bg-secondary-lt" x-text="connector.kind"></span>
953
+ </div>
954
+ <div class="text-secondary small mt-1" x-text="connector.description || connector.packageName || connector.binary"></div>
955
+ <div class="d-flex gap-1 mt-2 flex-wrap">
956
+ <template x-for="capability in (connector.capabilities || [])" :key="capability">
957
+ <span class="badge bg-cyan-lt" x-text="capability"></span>
958
+ </template>
959
+ </div>
960
+ <div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
961
+ <span x-text="'v' + connector.version"></span>
962
+ <span x-show="connector.runtime?.detail" x-text="connector.runtime.detail"></span>
963
+ <span x-show="connector.runtime?.updatedAt" x-text="'Updated ' + timeAgo(connector.runtime.updatedAt)"></span>
964
+ </div>
965
+ </div>
966
+ </div>
967
+ <div class="d-flex gap-1 mt-3 flex-wrap">
968
+ <template x-for="action in ['status','doctor','start','stop','restart','enable','disable']" :key="connector.id + action">
969
+ <button
970
+ class="btn btn-sm btn-ghost-secondary"
971
+ x-show="connector.manifest?.commands?.[action]"
972
+ @click="runConnectorAction(connector, action)"
973
+ :title="action.charAt(0).toUpperCase() + action.slice(1)">
974
+ <i class="ti" :class="action === 'doctor' ? 'ti-stethoscope' : action === 'status' ? 'ti-activity' : action === 'start' ? 'ti-player-play' : action === 'stop' ? 'ti-player-stop' : action === 'restart' ? 'ti-refresh' : action === 'enable' ? 'ti-toggle-right' : 'ti-toggle-left'"></i>
975
+ </button>
976
+ </template>
977
+ </div>
978
+ </div>
979
+ </div>
980
+ </div>
981
+ </template>
982
+ </div>
983
+
984
+ <template x-if="connectorCards.length === 0">
985
+ <div class="card">
986
+ <div class="card-body text-center text-secondary py-5">
987
+ <i class="ti ti-plug-off" style="font-size:48px; opacity:0.3"></i>
988
+ <p class="mt-2">No connectors registered</p>
989
+ </div>
990
+ </div>
991
+ </template>
992
+ </div>
993
+
914
994
  <!-- ==================== INTEGRATIONS ==================== -->
915
995
  <div x-show="view === 'integrations'" x-cloak class="fade-in">
916
996
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
@@ -1164,7 +1244,7 @@ agent-relay-orchestrator</pre>
1164
1244
  <template x-if="m.subject">
1165
1245
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
1166
1246
  </template>
1167
- <div class="msg-body" x-text="m.body"></div>
1247
+ <div class="msg-body" x-text="messageBody(m)"></div>
1168
1248
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
1169
1249
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
1170
1250
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -1419,7 +1499,7 @@ agent-relay-orchestrator</pre>
1419
1499
  <template x-if="m.subject">
1420
1500
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
1421
1501
  </template>
1422
- <div class="msg-body" x-text="m.body"></div>
1502
+ <div class="msg-body" x-text="messageBody(m)"></div>
1423
1503
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
1424
1504
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
1425
1505
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -2388,7 +2468,7 @@ agent-relay-orchestrator</pre>
2388
2468
  <template x-if="m.subject">
2389
2469
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
2390
2470
  </template>
2391
- <div class="msg-body" x-text="m.body"></div>
2471
+ <div class="msg-body" x-text="messageBody(m)"></div>
2392
2472
  </div>
2393
2473
  </template>
2394
2474
  <template x-if="threadMessages.length === 0">
@@ -2481,6 +2561,13 @@ agent-relay-orchestrator</pre>
2481
2561
  <script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
2482
2562
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
2483
2563
  <script src="dashboard.js"></script>
2564
+ <script>
2565
+ if ("serviceWorker" in navigator) {
2566
+ window.addEventListener("load", () => {
2567
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
2568
+ });
2569
+ }
2570
+ </script>
2484
2571
 
2485
2572
  </body>
2486
2573
  </html>
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "Agent Relay",
3
+ "short_name": "Relay",
4
+ "description": "Local control panel for Agent Relay agents, channels, tasks, and messages.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#0d1117",
10
+ "theme_color": "#0d1117",
11
+ "orientation": "any",
12
+ "categories": ["developer", "productivity", "utilities"],
13
+ "icons": [
14
+ {
15
+ "src": "/icons/agent-relay.svg",
16
+ "sizes": "any",
17
+ "type": "image/svg+xml",
18
+ "purpose": "any maskable"
19
+ },
20
+ {
21
+ "src": "/icons/agent-relay-192.png",
22
+ "sizes": "192x192",
23
+ "type": "image/png",
24
+ "purpose": "any maskable"
25
+ },
26
+ {
27
+ "src": "/icons/agent-relay-512.png",
28
+ "sizes": "512x512",
29
+ "type": "image/png",
30
+ "purpose": "any maskable"
31
+ }
32
+ ]
33
+ }
package/public/sw.js ADDED
@@ -0,0 +1,58 @@
1
+ const CACHE_NAME = "agent-relay-dashboard-v1";
2
+ const APP_SHELL = [
3
+ "/",
4
+ "/index.html",
5
+ "/dashboard.js",
6
+ "/manifest.webmanifest",
7
+ "/icons/agent-relay.svg",
8
+ "/icons/agent-relay-192.png",
9
+ "/icons/agent-relay-512.png",
10
+ ];
11
+
12
+ self.addEventListener("install", (event) => {
13
+ event.waitUntil(
14
+ caches.open(CACHE_NAME)
15
+ .then((cache) => cache.addAll(APP_SHELL))
16
+ .then(() => self.skipWaiting()),
17
+ );
18
+ });
19
+
20
+ self.addEventListener("activate", (event) => {
21
+ event.waitUntil(
22
+ caches.keys()
23
+ .then((names) => Promise.all(names
24
+ .filter((name) => name !== CACHE_NAME)
25
+ .map((name) => caches.delete(name))))
26
+ .then(() => self.clients.claim()),
27
+ );
28
+ });
29
+
30
+ self.addEventListener("fetch", (event) => {
31
+ const request = event.request;
32
+ const url = new URL(request.url);
33
+
34
+ if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
35
+ return;
36
+ }
37
+
38
+ if (request.headers.get("accept")?.includes("text/event-stream")) {
39
+ return;
40
+ }
41
+
42
+ event.respondWith(
43
+ fetch(request)
44
+ .then((response) => {
45
+ if (response.ok && APP_SHELL.includes(url.pathname === "/" ? "/" : url.pathname)) {
46
+ const copy = response.clone();
47
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
48
+ }
49
+ return response;
50
+ })
51
+ .catch(async () => {
52
+ const cached = await caches.match(request);
53
+ if (cached) return cached;
54
+ if (request.mode === "navigate") return caches.match("/index.html");
55
+ throw new Error("offline");
56
+ }),
57
+ );
58
+ });