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/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>
@@ -210,10 +217,20 @@
210
217
  <span class="badge bg-success text-white" x-show="onlineCount > 0" x-text="onlineCount"></span>
211
218
  </span>
212
219
  </a>
220
+ <a href="#" class="nav-link" :class="{ active: view === 'orchestrators' }" @click.prevent="switchView('orchestrators')">
221
+ <i class="ti ti-server-2"></i>Orchestrators
222
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
223
+ <span class="badge bg-success text-white" x-show="orchestrators.filter(o => o.status === 'online').length > 0" x-text="orchestrators.filter(o => o.status === 'online').length"></span>
224
+ </span>
225
+ </a>
213
226
  <a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
214
227
  <i class="ti ti-messages"></i>Channels
215
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>
216
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>
217
234
  <a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
218
235
  <i class="ti ti-plug-connected"></i>Integrations
219
236
  <span class="ms-auto d-inline-flex gap-1 align-items-center">
@@ -286,7 +303,7 @@
286
303
 
287
304
  <!-- Mobile nav -->
288
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">
289
- <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']">
290
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>
291
308
  </template>
292
309
  </div>
@@ -658,6 +675,185 @@
658
675
  </template>
659
676
  </div>
660
677
 
678
+ <!-- ==================== ORCHESTRATORS ==================== -->
679
+ <div x-show="view === 'orchestrators'" x-cloak class="fade-in">
680
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
681
+ <h2 class="page-title mb-0">Orchestrators</h2>
682
+ <span class="badge bg-success-lt" x-show="orchestrators.filter(o => o.status === 'online').length > 0"
683
+ x-text="orchestrators.filter(o => o.status === 'online').length + ' online'"></span>
684
+ <div class="ms-auto">
685
+ <button class="btn btn-sm btn-primary" @click="openOrchestratorSpawn()" :disabled="orchestrators.filter(o => o.status === 'online').length === 0">
686
+ <i class="ti ti-plus me-1"></i>Spawn Agent
687
+ </button>
688
+ </div>
689
+ </div>
690
+
691
+ <template x-if="orchestrators.length > 0">
692
+ <div class="row g-3">
693
+ <template x-for="orch in orchestrators" :key="orch.id">
694
+ <div class="col-12 col-lg-6">
695
+ <div class="card">
696
+ <div class="card-body">
697
+ <div class="d-flex align-items-center mb-3">
698
+ <i class="ti ti-server-2 me-2" style="font-size: 24px"></i>
699
+ <div>
700
+ <h3 class="mb-0" x-text="orch.hostname"></h3>
701
+ <small class="text-secondary" x-text="orch.id"></small>
702
+ </div>
703
+ <span class="ms-auto badge" :class="orch.status === 'online' ? 'bg-success' : 'bg-secondary'" x-text="orch.status"></span>
704
+ </div>
705
+
706
+ <div class="d-flex gap-3 mb-3 text-secondary" style="font-size: 13px">
707
+ <span><i class="ti ti-folder me-1"></i><span x-text="orch.baseDir"></span></span>
708
+ <span><i class="ti ti-key me-1"></i><span x-text="(orch.envKeys?.length || 0) + ' env vars'"></span></span>
709
+ </div>
710
+
711
+ <div class="d-flex gap-1 mb-3">
712
+ <template x-for="provider in orch.providers" :key="provider">
713
+ <span class="badge" :class="provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="provider"></span>
714
+ </template>
715
+ </div>
716
+
717
+ <template x-if="orch.managedAgents?.length > 0">
718
+ <div>
719
+ <h4 class="mb-2" style="font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--tblr-secondary)">
720
+ Managed Agents (<span x-text="orch.managedAgents.length"></span>)
721
+ </h4>
722
+ <div class="list-group list-group-flush">
723
+ <template x-for="agent in orch.managedAgents" :key="agent.tmuxSession">
724
+ <div class="list-group-item px-0 py-2 d-flex align-items-center gap-2">
725
+ <span class="badge" :class="agent.provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="agent.provider" style="font-size: 10px"></span>
726
+ <span x-text="agent.label || agent.tmuxSession" style="font-size: 13px"></span>
727
+ <small class="text-secondary" x-text="agent.cwd" style="font-size: 11px"></small>
728
+ <div class="ms-auto d-flex gap-1">
729
+ <button class="btn btn-sm btn-ghost-warning p-1" title="Restart"
730
+ @click="orchestratorAction(orch.id, 'restart', agent.agentId || agent.tmuxSession)">
731
+ <i class="ti ti-refresh" style="font-size: 14px"></i>
732
+ </button>
733
+ <button class="btn btn-sm btn-ghost-danger p-1" title="Shutdown"
734
+ @click="orchestratorAction(orch.id, 'shutdown', agent.agentId || agent.tmuxSession)">
735
+ <i class="ti ti-power" style="font-size: 14px"></i>
736
+ </button>
737
+ </div>
738
+ </div>
739
+ </template>
740
+ </div>
741
+ </div>
742
+ </template>
743
+ <template x-if="!orch.managedAgents?.length">
744
+ <p class="text-secondary mb-0" style="font-size: 13px">No managed agents</p>
745
+ </template>
746
+
747
+ <div class="d-flex gap-1 mt-3">
748
+ <button class="btn btn-sm btn-primary" @click="openOrchestratorSpawnFor(orch.id)" :disabled="orch.status !== 'online'">
749
+ <i class="ti ti-plus me-1"></i>Spawn
750
+ </button>
751
+ <button class="btn btn-sm btn-ghost-danger" @click="deleteOrchestrator(orch.id)">
752
+ <i class="ti ti-trash me-1"></i>Remove
753
+ </button>
754
+ </div>
755
+ </div>
756
+ <div class="card-footer text-secondary" style="font-size: 12px">
757
+ Last seen: <span x-text="timeAgo(orch.lastSeen)"></span>
758
+ </div>
759
+ </div>
760
+ </div>
761
+ </template>
762
+ </div>
763
+ </template>
764
+
765
+ <template x-if="orchestrators.length === 0">
766
+ <div class="card">
767
+ <div class="card-body text-center py-5">
768
+ <i class="ti ti-server-2 mb-3" style="font-size: 48px; color: var(--tblr-secondary)"></i>
769
+ <h3>No orchestrators registered</h3>
770
+ <p class="text-secondary">Install and start <code>agent-relay-orchestrator</code> on each host to enable remote agent spawning.</p>
771
+ <pre class="text-start mx-auto" style="max-width: 500px; font-size: 12px">npm install -g agent-relay-orchestrator
772
+ agent-relay-orchestrator init
773
+ agent-relay-orchestrator</pre>
774
+ </div>
775
+ </div>
776
+ </template>
777
+ </div>
778
+
779
+ <!-- Orchestrator Spawn Modal -->
780
+ <div class="modal" :class="{ show: orchestratorSpawnOpen }" tabindex="-1" :style="orchestratorSpawnOpen ? 'display:block' : 'display:none'" @click.self="orchestratorSpawnOpen = false">
781
+ <div class="modal-dialog modal-dialog-centered">
782
+ <div class="modal-content">
783
+ <div class="modal-header">
784
+ <h5 class="modal-title">Spawn Agent</h5>
785
+ <button type="button" class="btn-close" @click="orchestratorSpawnOpen = false"></button>
786
+ </div>
787
+ <div class="modal-body">
788
+ <div class="mb-3">
789
+ <label class="form-label">Orchestrator</label>
790
+ <select class="form-select" x-model="spawnOrchId">
791
+ <template x-for="orch in orchestrators.filter(o => o.status === 'online')" :key="orch.id">
792
+ <option :value="orch.id" x-text="orch.hostname + ' (' + orch.providers.join(', ') + ')'"></option>
793
+ </template>
794
+ </select>
795
+ </div>
796
+ <div class="mb-3">
797
+ <label class="form-label">Provider</label>
798
+ <select class="form-select" x-model="spawnProvider">
799
+ <template x-for="p in spawnAvailableProviders" :key="p">
800
+ <option :value="p" x-text="p"></option>
801
+ </template>
802
+ </select>
803
+ </div>
804
+ <div class="mb-3">
805
+ <label class="form-label">Working Directory</label>
806
+ <div class="input-group">
807
+ <input type="text" class="form-control" x-model="spawnCwd" placeholder="Leave empty for base directory">
808
+ <button class="btn btn-outline-secondary" @click="browseOrchestratorDirs()" type="button">
809
+ <i class="ti ti-folder"></i>
810
+ </button>
811
+ </div>
812
+ <template x-if="spawnDirListing">
813
+ <div class="border rounded mt-2 p-2" style="max-height: 200px; overflow-y: auto; font-size: 13px">
814
+ <template x-if="spawnDirListing.parent">
815
+ <a href="#" class="d-block py-1 text-secondary" @click.prevent="spawnCwd = spawnDirListing.parent; browseOrchestratorDirs()">
816
+ <i class="ti ti-arrow-up me-1"></i>..
817
+ </a>
818
+ </template>
819
+ <template x-for="entry in spawnDirListing.entries" :key="entry.path">
820
+ <a href="#" class="d-block py-1" @click.prevent="spawnCwd = entry.path; browseOrchestratorDirs()">
821
+ <i class="ti ti-folder me-1"></i><span x-text="entry.name"></span>
822
+ </a>
823
+ </template>
824
+ </div>
825
+ </template>
826
+ </div>
827
+ <div class="mb-3">
828
+ <label class="form-label">Label</label>
829
+ <input type="text" class="form-control" x-model="spawnLabel" placeholder="e.g. backend, reviewer">
830
+ </div>
831
+ <div class="mb-3">
832
+ <label class="form-label">Approval Mode</label>
833
+ <select class="form-select" x-model="spawnApproval">
834
+ <option value="guarded">Guarded (block destructive ops)</option>
835
+ <option value="open">Open (no restrictions)</option>
836
+ <option value="read-only">Read-only (observe only)</option>
837
+ </select>
838
+ </div>
839
+ <template x-if="spawnProvider === 'claude'">
840
+ <div class="mb-3">
841
+ <label class="form-label">Initial Prompt</label>
842
+ <textarea class="form-control" x-model="spawnPrompt" rows="3" placeholder="You are a headless relay agent..."></textarea>
843
+ </div>
844
+ </template>
845
+ </div>
846
+ <div class="modal-footer">
847
+ <button class="btn btn-secondary" @click="orchestratorSpawnOpen = false">Cancel</button>
848
+ <button class="btn btn-primary" @click="submitOrchestratorSpawn()">
849
+ <i class="ti ti-rocket me-1"></i>Spawn
850
+ </button>
851
+ </div>
852
+ </div>
853
+ </div>
854
+ </div>
855
+ <div class="modal-backdrop fade show" x-show="orchestratorSpawnOpen" x-cloak></div>
856
+
661
857
  <!-- ==================== CHANNELS ==================== -->
662
858
  <div x-show="view === 'channels'" x-cloak class="fade-in">
663
859
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
@@ -726,6 +922,75 @@
726
922
  </template>
727
923
  </div>
728
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
+
729
994
  <!-- ==================== INTEGRATIONS ==================== -->
730
995
  <div x-show="view === 'integrations'" x-cloak class="fade-in">
731
996
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
@@ -979,7 +1244,7 @@
979
1244
  <template x-if="m.subject">
980
1245
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
981
1246
  </template>
982
- <div class="msg-body" x-text="m.body"></div>
1247
+ <div class="msg-body" x-text="messageBody(m)"></div>
983
1248
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
984
1249
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
985
1250
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -1234,7 +1499,7 @@
1234
1499
  <template x-if="m.subject">
1235
1500
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
1236
1501
  </template>
1237
- <div class="msg-body" x-text="m.body"></div>
1502
+ <div class="msg-body" x-text="messageBody(m)"></div>
1238
1503
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
1239
1504
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
1240
1505
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -2203,7 +2468,7 @@
2203
2468
  <template x-if="m.subject">
2204
2469
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
2205
2470
  </template>
2206
- <div class="msg-body" x-text="m.body"></div>
2471
+ <div class="msg-body" x-text="messageBody(m)"></div>
2207
2472
  </div>
2208
2473
  </template>
2209
2474
  <template x-if="threadMessages.length === 0">
@@ -2296,6 +2561,13 @@
2296
2561
  <script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
2297
2562
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
2298
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>
2299
2571
 
2300
2572
  </body>
2301
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
+ });
package/src/cli.ts CHANGED
@@ -485,11 +485,13 @@ async function handleMessageCommand(args: string[], defaults: { claimable?: bool
485
485
  const message = await apiRequest("POST", "/api/messages", {
486
486
  from,
487
487
  to: target,
488
+ kind: claimable ? "task" : "chat",
488
489
  subject,
489
490
  channel,
490
491
  body,
491
492
  replyTo,
492
493
  claimable,
494
+ payload: claimable ? { title: subject || "Claimable task" } : undefined,
493
495
  idempotencyKey,
494
496
  });
495
497
  if (json) console.log(JSON.stringify(message, null, 2));
@@ -606,9 +608,16 @@ async function detectAgentId(): Promise<string | undefined> {
606
608
  const explicit = process.env.AGENT_RELAY_ID;
607
609
  if (explicit) return explicit;
608
610
 
611
+ const contextMatch = currentAgentContextId();
612
+ if (contextMatch) return contextMatch;
613
+
609
614
  const cwd = process.cwd();
615
+ const explicitCodexState = process.env.AGENT_RELAY_CODEX_STATE_PATH
616
+ ? readCodexState(process.env.AGENT_RELAY_CODEX_STATE_PATH)
617
+ : null;
618
+ if (explicitCodexState?.agentId) return explicitCodexState.agentId;
619
+
610
620
  const stateCandidates = [
611
- process.env.AGENT_RELAY_CODEX_STATE_PATH,
612
621
  resolve(cwd, "codex/runtime/live-state.json"),
613
622
  ...collectCodexStateFiles(),
614
623
  ].filter((path): path is string => Boolean(path));
@@ -616,7 +625,7 @@ async function detectAgentId(): Promise<string | undefined> {
616
625
  const codexMatch = newestCodexAgentId(stateCandidates, cwd);
617
626
  if (codexMatch) return codexMatch;
618
627
 
619
- const claudeMatch = newestClaudeAgentId();
628
+ const claudeMatch = currentClaudeAgentId();
620
629
  if (claudeMatch) return claudeMatch;
621
630
 
622
631
  try {
@@ -624,18 +633,77 @@ async function detectAgentId(): Promise<string | undefined> {
624
633
  const cwdAgents = agents
625
634
  .filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
626
635
  .sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
627
- return cwdAgents[0]?.id;
636
+ const uniqueAgentIds = uniqueStrings(cwdAgents.map((agent) => agent.id!));
637
+ return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
628
638
  } catch {
629
639
  return undefined;
630
640
  }
631
641
  }
632
642
 
643
+ function currentAgentContextId(): string | undefined {
644
+ const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
645
+ if (explicitPath) {
646
+ const explicit = readAgentContext(explicitPath);
647
+ if (explicit?.agentId) return explicit.agentId;
648
+ }
649
+
650
+ const candidates = collectAgentContextFiles();
651
+ const matches = candidates
652
+ .map((path) => readAgentContext(path))
653
+ .filter((context): context is { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } => Boolean(context))
654
+ .filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
655
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
656
+
657
+ const uniqueAgentIds = uniqueStrings(matches.map((context) => context.agentId));
658
+ return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
659
+ }
660
+
661
+ function readAgentContext(path: string): { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } | null {
662
+ if (!existsSync(path)) return null;
663
+ try {
664
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
665
+ agentId?: unknown;
666
+ updatedAt?: unknown;
667
+ matchEnv?: unknown;
668
+ };
669
+ if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
670
+ const matchEnv = Array.isArray(parsed.matchEnv)
671
+ ? parsed.matchEnv.flatMap((item) => {
672
+ if (!item || typeof item !== "object") return [];
673
+ const record = item as { name?: unknown; value?: unknown };
674
+ return typeof record.name === "string" && typeof record.value === "string"
675
+ ? [{ name: record.name, value: record.value }]
676
+ : [];
677
+ })
678
+ : [];
679
+ const stat = statSync(path);
680
+ const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
681
+ return {
682
+ agentId: parsed.agentId,
683
+ matchEnv,
684
+ updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
685
+ };
686
+ } catch {
687
+ return null;
688
+ }
689
+ }
690
+
691
+ function collectAgentContextFiles(): string[] {
692
+ const roots = [
693
+ join(process.env.HOME || "", ".agent-relay", "contexts"),
694
+ ].filter((root) => root && existsSync(root));
695
+ const files: string[] = [];
696
+ for (const root of roots) collectFiles(root, ".json", files, 2);
697
+ return files;
698
+ }
699
+
633
700
  function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
634
701
  const states = paths
635
702
  .map((path) => readCodexState(path))
636
703
  .filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
637
704
  .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
638
- return states.find((state) => state.cwd === cwd)?.agentId ?? states[0]?.agentId;
705
+ const cwdAgentIds = uniqueStrings(states.filter((state) => state.cwd === cwd).map((state) => state.agentId));
706
+ return cwdAgentIds.length === 1 ? cwdAgentIds[0] : undefined;
639
707
  }
640
708
 
641
709
  function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
@@ -678,29 +746,24 @@ function collectFiles(dir: string, name: string, output: string[], depth: number
678
746
  try {
679
747
  const stat = statSync(path);
680
748
  if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
681
- else if (entry === name) output.push(path);
749
+ else if (name.startsWith(".") ? entry.endsWith(name) : entry === name) output.push(path);
682
750
  } catch {
683
751
  // Ignore state files that disappear while scanning.
684
752
  }
685
753
  }
686
754
  }
687
755
 
688
- function newestClaudeAgentId(): string | undefined {
689
- if (!existsSync("/tmp")) return undefined;
756
+ function currentClaudeAgentId(): string | undefined {
757
+ const sessionKey = process.env.CLAUDE_CODE_SESSION_ID || String(process.ppid || "");
758
+ if (!sessionKey) return undefined;
759
+ const safeSessionKey = sessionKey.replace(/[^A-Za-z0-9_.:-]/g, "_");
760
+ const statePath = join("/tmp", `agent-relay-instance-${safeSessionKey}.state`);
761
+ if (!existsSync(statePath)) return undefined;
690
762
  try {
691
- const candidates = readdirSync("/tmp")
692
- .filter((entry) => entry.startsWith("agent-relay-instance-") && entry.endsWith(".state"))
693
- .map((entry) => join("/tmp", entry))
694
- .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
695
- .sort((a, b) => b.mtimeMs - a.mtimeMs);
696
- for (const candidate of candidates) {
697
- const id = readFileSync(candidate.path, "utf8").split(/\r?\n/)[0]?.trim();
698
- if (id) return id;
699
- }
763
+ return readFileSync(statePath, "utf8").split(/\r?\n/)[0]?.trim() || undefined;
700
764
  } catch {
701
765
  return undefined;
702
766
  }
703
- return undefined;
704
767
  }
705
768
 
706
769
  function formatPairs(pairs: any[]): string {