@flame0510/project-aether 1.1.12 → 1.1.14

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.
Files changed (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. package/lib/requireAuth.tsx +0 -38
package/README.md CHANGED
@@ -44,7 +44,10 @@ rev4a serve --port 8080
44
44
  ```
45
45
 
46
46
  > **Production install:** see `CONNECT.md` for systemd deployment on a VPS.
47
- > **Container deploy:** `docker run -d --name rev4a -v rev4a-data:/rev4a/data -v /var/run/docker.sock:/var/run/docker.sock -p 3740:3740 ghcr.io/flame0510/rev4a:latest`
47
+ >
48
+ > **Container deploy:** on hold. The root `Dockerfile` (Docker-in-Docker packaging of
49
+ > the dashboard itself) is an experimental path, not currently built or published —
50
+ > see the file for details. Use the git-clone install above.
48
51
 
49
52
  ---
50
53
 
@@ -42,11 +42,30 @@ interface Agent {
42
42
  ip: string | null;
43
43
  created: string | null;
44
44
  env: string[];
45
- traefikUrl: string | null;
45
+ controlPort: string | null;
46
46
  authToken: string | null;
47
47
  imageOutdated?: boolean;
48
48
  }
49
49
 
50
+ /**
51
+ * URL of the agent's OpenClaw Control UI.
52
+ *
53
+ * Composed here rather than on the server on purpose. The hostname the dashboard
54
+ * is currently being reached on is, by definition, one that resolves and routes
55
+ * for this user — `localhost` on a dev machine, a LAN address from a phone, the
56
+ * VPS IP, or a domain. The server can only guess, and it guessed badly: it shelled
57
+ * out to `curl icanhazip.com` on every call and, on macOS, ended up returning the
58
+ * machine's `.local` mDNS name.
59
+ *
60
+ * Returns null when the agent has no published port (nothing to link to) or when
61
+ * called during a server render, where `window` doesn't exist.
62
+ */
63
+ function agentControlUrl(agent: Agent): string | null {
64
+ if (!agent.controlPort || typeof window === 'undefined') return null;
65
+ const base = `http://${window.location.hostname}:${agent.controlPort}`;
66
+ return agent.authToken ? `${base}#token=${encodeURIComponent(agent.authToken)}` : base;
67
+ }
68
+
50
69
  type FilterState = 'all' | 'running' | 'exited';
51
70
 
52
71
  const STATUS_BULLET: Record<string, string> = {
@@ -180,11 +199,17 @@ function AgentListItem({
180
199
  )}
181
200
  <div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
182
201
  {loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
183
- {agent.state === 'running' && agent.traefikUrl && (
202
+ {agent.state === 'running' && agent.controlPort && (
184
203
  <Button
185
204
  variant="primary"
186
205
  size="sm"
187
- onClick={(e) => { e.stopPropagation(); window.open(agent.traefikUrl!, '_blank'); }}
206
+ // Gate on the port, resolve the URL on click: the condition stays
207
+ // safe to evaluate during a server render, the URL never is.
208
+ onClick={(e) => {
209
+ e.stopPropagation();
210
+ const url = agentControlUrl(agent);
211
+ if (url) window.open(url, '_blank');
212
+ }}
188
213
  title="Open agent Control UI"
189
214
  >
190
215
  Open ↗
@@ -216,18 +241,23 @@ export default function AgentsPageClient() {
216
241
  const [loading, setLoading] = useState(true);
217
242
  const [error, setError] = useState('');
218
243
  const [filter, setFilter] = useState<FilterState>('all');
219
- const [selected, setSelected] = useState<Agent | null>(null);
244
+ const [selectedId, setSelectedId] = useState<string | null>(null);
220
245
  const [isMobile, setIsMobile] = useState(false);
221
246
  const [showList, setShowList] = useState(true);
222
247
  const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
223
248
  const [imageReady, setImageReady] = useState(true);
224
- const [providersConfigured, setProvidersConfigured] = useState(false);
249
+ // null = not checked yet. Keeping "unknown" distinct from "none configured" is
250
+ // the whole point: `false` as the initial value makes an unanswered check look
251
+ // exactly like a negative answer, so the warning banner renders on no evidence.
252
+ const [providersConfigured, setProvidersConfigured] = useState<boolean | null>(null);
225
253
  const [gatewayToken, setGatewayToken] = useState('');
226
254
  const [tokenLoading, setTokenLoading] = useState(true);
227
255
  const [tokenSaving, setTokenSaving] = useState(false);
228
256
  const [tokenStatus, setTokenStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
229
257
  const [lifecycleLoading, setLifecycleLoading] = useState<string | null>(null);
230
258
  const skipNextPoll = useRef(false);
259
+ /** Guards the un-awaited channel-summary fetch against out-of-order responses. */
260
+ const chSummarySeq = useRef(0);
231
261
  useEffect(() => {
232
262
  const check = () => setIsMobile(window.innerWidth < 768);
233
263
  check();
@@ -243,11 +273,23 @@ export default function AgentsPageClient() {
243
273
  if (!Array.isArray(data)) throw new Error('Invalid response');
244
274
  setAgents(data);
245
275
  setError('');
246
- // Also load channel summary
247
- try {
248
- const chRes = await fetch('/api/agents/channels-summary');
249
- if (chRes.ok) setChSummary(await chRes.json());
250
- } catch { /* */ }
276
+
277
+ // Channel summary is deliberately not awaited: it only decorates rows with
278
+ // a TG badge, and awaiting it here held the whole page behind a second
279
+ // round trip — the agent list was ready in ~0.25s but stayed hidden for
280
+ // ~1.15s. It now lands on its own and the badges appear when it does.
281
+ //
282
+ // Sequence-guarded: load() also runs on a 15s poll, so several of these can
283
+ // be in flight at once. Without the check, a slow earlier response landing
284
+ // after a faster later one would overwrite fresh badges with stale ones.
285
+ const seq = ++chSummarySeq.current;
286
+ void (async () => {
287
+ try {
288
+ const chRes = await fetch('/api/agents/channels-summary');
289
+ if (!chRes.ok || seq !== chSummarySeq.current) return;
290
+ setChSummary(await chRes.json());
291
+ } catch { /* badges are optional */ }
292
+ })();
251
293
  } catch (e: any) {
252
294
  setError(e.message || 'Loading error');
253
295
  } finally {
@@ -274,7 +316,8 @@ export default function AgentsPageClient() {
274
316
 
275
317
  // Check if any provider is configured in the Gateway
276
318
  useEffect(() => {
277
- fetch('/api/gateway/provider')
319
+ // summary=1: skips the live-pricing round trip we don't need here.
320
+ fetch('/api/gateway/provider?summary=1')
278
321
  .then((r) => r.json())
279
322
  .then((d: { providers?: Array<{ configured: boolean }> }) =>
280
323
  setProvidersConfigured((d.providers || []).some((p) => p.configured))
@@ -358,8 +401,36 @@ export default function AgentsPageClient() {
358
401
  return list;
359
402
  }, [agents, filter]);
360
403
 
404
+ // Derive the selected agent from the live list rather than holding a snapshot.
405
+ // A snapshot goes stale on every 15s poll (status, ports, imageOutdated) and
406
+ // survives deletion; deriving means a rename shows up on the next load() and a
407
+ // deleted agent can't leave a detail panel pointing at something that's gone.
408
+ const selected = useMemo(
409
+ () => (selectedId ? agents.find((a) => a.agentId === selectedId) ?? null : null),
410
+ [agents, selectedId],
411
+ );
412
+
413
+ // The selection can vanish under us — deleted here, or from another tab/CLI
414
+ // and picked up by the poll. Drop it so mobile falls back to the list instead
415
+ // of rendering a dead detail view.
416
+ useEffect(() => {
417
+ if (selectedId && !agents.some((a) => a.agentId === selectedId)) {
418
+ setSelectedId(null);
419
+ setShowList(true);
420
+ }
421
+ }, [agents, selectedId]);
422
+
423
+ /** An agent was deleted: drop it optimistically, then reconcile with the server. */
424
+ const handleAgentDeleted = useCallback((agentId: string) => {
425
+ // The DELETE already succeeded server-side, so removing it here is safe and
426
+ // spares the user the round trip — the API tears down container, volume and
427
+ // backups before it answers, which takes seconds.
428
+ setAgents((prev) => prev.filter((a) => a.agentId !== agentId));
429
+ void load();
430
+ }, [load]);
431
+
361
432
  function selectAgent(agent: Agent) {
362
- setSelected(agent);
433
+ setSelectedId(agent.agentId);
363
434
  if (isMobile) setShowList(false);
364
435
  }
365
436
 
@@ -396,7 +467,11 @@ export default function AgentsPageClient() {
396
467
  </div>
397
468
  );
398
469
 
399
- if (loading) {
470
+ // Hold the skeleton until the provider check has answered too. Both effects
471
+ // fire on mount, so this costs nothing when the check is warm (~3ms) and only
472
+ // shows up on a cold one (~230ms) — where it's exactly what we want, since
473
+ // that's the window the banner used to flash in.
474
+ if (loading || providersConfigured === null) {
400
475
  return (
401
476
  <div style={{ padding: 14, display: 'grid', gap: 14 }}>
402
477
  {Array.from({ length: 3 }).map((_, i) => (
@@ -433,10 +508,10 @@ export default function AgentsPageClient() {
433
508
  <span style={{ fontSize: 12, color: 'var(--violet)' }}>{selected.name}</span>
434
509
  </div>
435
510
  <ImageDownloadBanner onImageReady={handleImageReady} />
436
- {!providersConfigured && <NoProviderBanner />}
511
+ {providersConfigured === false && <NoProviderBanner />}
437
512
  {/* Scrollable content */}
438
513
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
439
- <DetailPanel agent={selected} />
514
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
440
515
  </div>
441
516
  </div>
442
517
  );
@@ -460,7 +535,7 @@ export default function AgentsPageClient() {
460
535
  ))}
461
536
  </div>
462
537
  <ImageDownloadBanner onImageReady={handleImageReady} />
463
- {!providersConfigured && <NoProviderBanner />}
538
+ {providersConfigured === false && <NoProviderBanner />}
464
539
  {tokenSection}
465
540
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
466
541
  {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
@@ -499,13 +574,15 @@ export default function AgentsPageClient() {
499
574
  ))}
500
575
  </div>
501
576
  <ImageDownloadBanner onImageReady={handleImageReady} />
502
- {!providersConfigured && <NoProviderBanner />}
577
+ {providersConfigured === false && <NoProviderBanner />}
503
578
  {tokenSection}
504
579
  <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
505
580
  <div style={{ width: '38%', minWidth: 300, borderRight: '1px solid var(--border)', overflow: 'auto' }}>
506
581
  {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
507
582
  {filtered.map((agent) => {
508
- const isSelected = selected?.id === agent.id;
583
+ // Match on agentId, not the container id: recreate gives the agent a
584
+ // new container (new id) and would otherwise silently deselect it.
585
+ const isSelected = selectedId === agent.agentId;
509
586
 
510
587
  return (
511
588
  <AgentListItem
@@ -514,7 +591,7 @@ export default function AgentsPageClient() {
514
591
  loading={lifecycleLoading === agent.agentId}
515
592
  channelSummary={chSummary}
516
593
  selected={isSelected}
517
- onClick={() => setSelected(agent)}
594
+ onClick={() => setSelectedId(agent.agentId)}
518
595
  onLifecycle={() => handleLifecycle(agent)}
519
596
  />
520
597
  );
@@ -524,7 +601,10 @@ export default function AgentsPageClient() {
524
601
  {!selected ? (
525
602
  <div style={{ color: '#666', fontSize: 12, padding: 20 }}>Select an agent for details</div>
526
603
  ) : (
527
- <DetailPanel agent={selected} />
604
+ // key: without it React reuses one DetailPanel instance across
605
+ // selections, so in-flight state (deleting, recreating, restarting,
606
+ // toasts, open confirms) leaks onto the next agent you click.
607
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
528
608
  )}
529
609
  </div>
530
610
  </div>
@@ -533,7 +613,12 @@ export default function AgentsPageClient() {
533
613
  }
534
614
 
535
615
  // ─── Edit Agent modal ───
536
- function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void }) {
616
+ function EditAgentModal({ agent, onClose, onSaved }: {
617
+ agent: Agent;
618
+ onClose: () => void;
619
+ /** Rename succeeded — the parent refetches so the new name propagates. */
620
+ onSaved: () => void;
621
+ }) {
537
622
  const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
538
623
  const [saving, setSaving] = useState(false);
539
624
  const [error, setError] = useState('');
@@ -558,8 +643,9 @@ function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void
558
643
  });
559
644
  const data = await res.json();
560
645
  if (!res.ok) throw new Error(data.error || 'Failed to update');
561
- // Reload to pick up the new display name
562
- window.location.reload();
646
+ // Refetch the list so the new display name propagates — no full reload.
647
+ onSaved();
648
+ onClose();
563
649
  } catch (e: unknown) {
564
650
  setError((e as Error).message);
565
651
  setSaving(false);
@@ -682,25 +768,39 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
682
768
  }
683
769
 
684
770
  // ─── Detail panel (shared between mobile & desktop) ───
685
- function DetailPanel({ agent }: { agent: Agent }) {
771
+ function DetailPanel({ agent, onDeleted, onChanged }: {
772
+ agent: Agent;
773
+ /** The agent is gone — the parent drops it from the list. */
774
+ onDeleted: (agentId: string) => void;
775
+ /** The agent changed in place (renamed, recreated) — refetch the list. */
776
+ onChanged: () => void;
777
+ }) {
686
778
  const [deleting, setDeleting] = useState(false);
687
779
  const [deleteError, setDeleteError] = useState('');
688
780
  const [showConfig, setShowConfig] = useState(false);
689
781
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
690
782
  const [showEdit, setShowEdit] = useState(false);
691
783
 
784
+ // Safe to resolve during render: this panel only mounts once the agent list has
785
+ // been fetched client-side, so it never runs in a server render. The helper
786
+ // guards for a missing `window` regardless.
787
+ const controlUrl = agentControlUrl(agent);
788
+
692
789
  const handleDelete = async () => {
693
790
  setDeleting(true);
694
791
  setDeleteError('');
695
792
  try {
696
- const res = await fetch(`/api/agents/${agent.name}`, { method: 'DELETE' });
793
+ const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, { method: 'DELETE' });
697
794
  const data = await res.json();
698
795
  if (!res.ok || !data.success) {
699
796
  setDeleteError(data.error || 'Delete failed');
700
797
  setDeleting(false);
701
798
  return;
702
799
  }
703
- window.location.reload();
800
+ setShowDeleteConfirm(false);
801
+ // No setDeleting(false): the parent drops the agent and this panel
802
+ // unmounts. Clearing state on an unmounted component would warn.
803
+ onDeleted(agent.agentId);
704
804
  } catch (e: unknown) {
705
805
  setDeleteError((e as Error).message || 'Network error');
706
806
  setDeleting(false);
@@ -778,15 +878,15 @@ function DetailPanel({ agent }: { agent: Agent }) {
778
878
  {/* Backup - persistent volume backup & restore */}
779
879
  <BackupSection agentName={agent.name} />
780
880
 
781
- {/* Access - agent URL (token inline in link) */}
782
- {agent.traefikUrl && (
881
+ {/* Access - agent Control UI URL (token inline in link) */}
882
+ {controlUrl && (
783
883
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
784
884
  <SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
785
885
  <div style={{ padding: '7px 14px', fontSize: 12 }}>
786
886
  <div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
787
- <a href={agent.traefikUrl} target="_blank" rel="noopener noreferrer"
887
+ <a href={controlUrl} target="_blank" rel="noopener noreferrer"
788
888
  style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
789
- >{agent.traefikUrl}</a>
889
+ >{controlUrl}</a>
790
890
 
791
891
  </div>
792
892
  </div>
@@ -796,7 +896,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
796
896
  {showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
797
897
 
798
898
  {/* Edit agent modal */}
799
- {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} />}
899
+ {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
800
900
 
801
901
  {/* Delete confirmation */}
802
902
  <ConfirmModal
@@ -819,7 +919,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
819
919
  </div>
820
920
  </div>
821
921
  )}
822
- <RecreateSection agentName={agent.name} />
922
+ <RecreateSection agentName={agent.name} onRecreated={onChanged} />
823
923
 
824
924
  {/* Delete */}
825
925
  <div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
@@ -1137,7 +1237,11 @@ function RestartSection({ agentName }: { agentName: string }) {
1137
1237
 
1138
1238
  /* ─── Recreate section - rebuild container from same image while keeping volume ─── */
1139
1239
 
1140
- function RecreateSection({ agentName }: { agentName: string }) {
1240
+ function RecreateSection({ agentName, onRecreated }: {
1241
+ agentName: string;
1242
+ /** The container was rebuilt — the parent refetches (new id, fresh image). */
1243
+ onRecreated: () => void;
1244
+ }) {
1141
1245
  const [recreating, setRecreating] = useState(false);
1142
1246
  const [error, setError] = useState('');
1143
1247
  const [toast, setToast] = useState<string | null>(null);
@@ -1157,7 +1261,9 @@ function RecreateSection({ agentName }: { agentName: string }) {
1157
1261
  const data = await res.json();
1158
1262
  if (data.success) {
1159
1263
  showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
1160
- setTimeout(() => window.location.reload(), 2000);
1264
+ // Refetch instead of reloading: the toast stays readable and the panel
1265
+ // keeps its scroll position while the new container id comes in.
1266
+ onRecreated();
1161
1267
  } else {
1162
1268
  setError(data.error || 'Recreate failed');
1163
1269
  }
@@ -77,9 +77,16 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
77
77
  const [fallbacks] = useState<string[]>([]);
78
78
  const [submitting, setSubmitting] = useState(false);
79
79
  const [errorMsg, setErrorMsg] = useState('');
80
- const [result, setResult] = useState<{ name: string; containerId: string; traefikUrl: string } | null>(null);
80
+ const [result, setResult] = useState<{ name: string; containerId: string; port: number; controlToken: string } | null>(null);
81
81
  const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
82
82
 
83
+ // The agent's Control UI URL, composed here rather than server-side: whatever
84
+ // hostname reached this dashboard is one that resolves and routes for this
85
+ // user, which the server can only guess at. Null until the create succeeds.
86
+ const controlUrl = result && typeof window !== 'undefined'
87
+ ? `http://${window.location.hostname}:${result.port}#token=${encodeURIComponent(result.controlToken)}`
88
+ : null;
89
+
83
90
  const handleSelectTemplate = (id: string) => {
84
91
  setSelectedTemplate(id);
85
92
  setAgentName('');
@@ -389,10 +396,10 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
389
396
  <div style={{ fontSize: 13, color: '#888', marginBottom: 4 }}>
390
397
  Name: <span style={{ color: 'var(--text)' }}>{result.name}</span>
391
398
  </div>
392
- {result.traefikUrl && (
399
+ {controlUrl && (
393
400
  <div style={{ fontSize: 13, color: '#888' }}>
394
- Access: <a href={result.traefikUrl} target="_blank" rel="noopener noreferrer"
395
- style={{ color: '#60a5fa', textDecoration: 'underline' }}>{result.traefikUrl}</a>
401
+ Access: <a href={controlUrl} target="_blank" rel="noopener noreferrer"
402
+ style={{ color: '#60a5fa', textDecoration: 'underline' }}>{controlUrl}</a>
396
403
  </div>
397
404
  )}
398
405
  </div>
@@ -181,9 +181,9 @@ export async function POST(request: Request,
181
181
  { timeout: 15000, stdio: 'pipe', input: providerJson },
182
182
  );
183
183
 
184
- try {
185
- execSync(`docker exec ${id} openclaw gateway restart`, { timeout: 15000 });
186
- } catch { /* non-fatal */ }
184
+ // No gateway restart: `config patch` reports "Change will apply without
185
+ // restarting the gateway" for a models-only patch, and the Gateway watches
186
+ // openclaw.json with gateway.reload defaulting to hybrid.
187
187
  } catch {
188
188
  // config patch is best-effort on recreate
189
189
  }
@@ -265,7 +265,9 @@ export async function PATCH(
265
265
  `docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
266
266
  { timeout: 15000, stdio: 'pipe', input: providerJson },
267
267
  );
268
- try { execSync(`docker exec ${id} openclaw gateway restart`, { timeout: 15000 }); } catch { /* non-fatal */ }
268
+ // No gateway restart: `config patch` reports "Change will apply without
269
+ // restarting the gateway" for a models-only patch, and the Gateway watches
270
+ // openclaw.json with gateway.reload defaulting to hybrid.
269
271
  } catch { /* non-fatal */ }
270
272
 
271
273
  return NextResponse.json({
@@ -1,36 +1,67 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { getChannels } from '@/lib/channelManager';
3
- import { execSync } from 'child_process';
2
+ import { execFile } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import { parseTelegramChannel } from '@/lib/channelManager';
5
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
4
6
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
7
 
8
+ const execFileAsync = promisify(execFile);
9
+
6
10
  export const dynamic = 'force-dynamic';
7
11
  export const maxDuration = 15; // 15-second timeout for channels summary
8
12
 
13
+ /** Agents read at once — each is one `docker exec … cat`. */
14
+ const AGENT_QUERY_CONCURRENCY = 8;
15
+
16
+ const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
17
+
9
18
  /**
10
19
  * GET /api/agents/channels-summary
11
20
  *
12
21
  * Returns a lightweight channel status map for all running agents.
13
22
  * { [agentId]: { telegram: boolean } }
23
+ *
24
+ * This used to call getChannels(agentId) in a loop, which re-ran
25
+ * `docker ps --filter label=AGENT_ID=<id>` for every agent just to recover the
26
+ * container name — a name this route's own `docker ps` had already returned and
27
+ * then discarded. So it was 1 + 2N synchronous process spawns to read N files.
28
+ * Now the discovery call carries the container name through, and the configs are
29
+ * read concurrently.
14
30
  */
15
- export async function GET(request: NextRequest,
16
- ): Promise<NextResponse> {
31
+ export async function GET(request: NextRequest): Promise<NextResponse> {
17
32
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
18
33
  try {
19
- // Discover agents with AGENT_ID label
20
- const out = execSync(
21
- `docker ps --filter "label=AGENT_ID" --format '{{.Label "AGENT_ID"}}' 2>/dev/null`,
22
- { encoding: 'utf-8', timeout: 5000 },
23
- ).trim();
34
+ // Keep the container name alongside the label — the follow-up lookups per
35
+ // agent existed only because this call used to throw the name away.
36
+ const { stdout } = await execFileAsync('docker', [
37
+ 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}',
38
+ ], { encoding: 'utf-8', timeout: 5000 });
24
39
 
25
- const agentIds = out ? out.split('\n').filter(Boolean) : [];
26
- const summary: Record<string, { telegram: boolean }> = {};
40
+ const agents = stdout.trim().split('\n')
41
+ .map((line) => {
42
+ const [agentId, containerName] = line.split('\t');
43
+ if (!agentId?.trim() || !containerName?.trim()) return null;
44
+ return { agentId: agentId.trim(), containerName: containerName.trim() };
45
+ })
46
+ .filter(Boolean) as { agentId: string; containerName: string }[];
47
+
48
+ const results = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, async (agent) => {
49
+ const raw = await dockerExecNoFail(
50
+ agent.containerName,
51
+ ['cat', AGENT_CONFIG_PATH],
52
+ { timeoutMs: 8000 },
53
+ );
54
+ if (!raw) return { agentId: agent.agentId, telegram: false };
55
+ try {
56
+ const config = JSON.parse(raw) as Record<string, unknown>;
57
+ return { agentId: agent.agentId, telegram: parseTelegramChannel(config)?.connected === true };
58
+ } catch {
59
+ return { agentId: agent.agentId, telegram: false };
60
+ }
61
+ });
27
62
 
28
- for (const agentId of agentIds) {
29
- const channels = getChannels(agentId);
30
- summary[agentId] = {
31
- telegram: channels.telegram?.connected === true,
32
- };
33
- }
63
+ const summary: Record<string, { telegram: boolean }> = {};
64
+ for (const r of results) summary[r.agentId] = { telegram: r.telegram };
34
65
 
35
66
  return NextResponse.json(summary);
36
67
  } catch {
@@ -4,7 +4,6 @@ import * as crypto from 'crypto';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import { buildRev4aProviderConfig } from '../../gateway/sync';
7
- import { detectPublicHost } from '@/lib/docker-utils';
8
7
  import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
9
8
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
10
9
  import { loadModelsConfig } from '@/lib/model-catalogue';
@@ -44,11 +43,23 @@ const DEFAULT_BLOCK_SIZE = 10;
44
43
 
45
44
  function getUsedHostPorts(): Set<number> {
46
45
  try {
46
+ // Parse host ports in JS rather than piping through grep. The previous
47
+ // form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
48
+ // loses its backslash — the shell received `d+(?=->)`, which never
49
+ // matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
50
+ // empty set and every agent was offered port 3700. `grep -P` is also
51
+ // GNU-only and absent on macOS. This mirrors the client-side parser in
52
+ // app/agents/create/page.tsx.
47
53
  const raw = execSync(
48
- `docker ps --format '{{.Ports}}' | grep -oP '\d+(?=->)' | sort -n`,
54
+ `docker ps --format '{{.Ports}}'`,
49
55
  { encoding: 'utf-8', timeout: 3000 },
50
- ).trim();
51
- return new Set(raw ? raw.split('\n').map(Number) : []);
56
+ );
57
+ const used = new Set<number>();
58
+ for (const [, port] of raw.matchAll(/(\d+)->/g)) {
59
+ const n = Number(port);
60
+ if (Number.isInteger(n)) used.add(n);
61
+ }
62
+ return used;
52
63
  } catch {
53
64
  return new Set();
54
65
  }
@@ -237,17 +248,23 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
237
248
  );
238
249
  }
239
250
 
240
- // Every agent gets a host port range for direct access (http://<ip>:<port>).
241
- const agentHostname = `${detectPublicHost()}:${portStart}`;
242
- const traefikUrl = `http://${agentHostname}#token=${encodeURIComponent(agentsToken)}`;
243
-
251
+ // Every agent gets a host port range for direct access. The URL is composed
252
+ // by the browser from `port` + `controlToken` in the response — only the
253
+ // browser knows a hostname that actually reaches this machine.
254
+ //
255
+ // There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
256
+ // Nothing ever read it: verified across this repo and across the whole
257
+ // filesystem of a running agent container, binaries included, with a control
258
+ // search proving the method finds AGENT_ID and OPENCLAW_GATEWAY_TOKEN in the
259
+ // same sweep. Worse, a container's env is immutable and recreate copies every
260
+ // AGENT_* var forward, so a wrong address was frozen in and propagated for the
261
+ // life of the agent, with no code path anywhere that could refresh it.
244
262
  const labels: string[] = [`AGENT_ID=${containerName}`];
245
263
  const envVars: string[] = [
246
264
  `AGENT_ID=${containerName}`,
247
265
  `AGENT_NAME=${displayName.trim()}`,
248
266
  `AGENT_TEMPLATE=${template}`,
249
267
  `OPENCLAW_GATEWAY_TOKEN=${agentsToken}`,
250
- `AGENT_HOSTNAME=${agentHostname}`,
251
268
  `TZ=Europe/Rome`,
252
269
  ];
253
270
 
@@ -425,9 +442,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
425
442
  { timeout: 15000, stdio: 'pipe', input: providerJson },
426
443
  );
427
444
 
428
- try {
429
- execSync(`docker exec ${containerName} openclaw gateway restart`, { timeout: 15000 });
430
- } catch { /* restart is best-effort */ }
445
+ // No gateway restart: `config patch` reports "Change will apply without
446
+ // restarting the gateway" for a models-only patch, and the Gateway watches
447
+ // openclaw.json with gateway.reload defaulting to hybrid. The earlier
448
+ // restart in this route stays — that one follows a full-file rewrite that
449
+ // includes gateway.*, which is the one section that genuinely needs it.
431
450
  } catch (e: unknown) {
432
451
  // runtime config patch is best-effort; log but don't fail the create
433
452
  console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
@@ -440,7 +459,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
440
459
  displayName: displayName.trim(),
441
460
  image,
442
461
  network,
443
- traefikUrl,
462
+ controlToken: agentsToken,
444
463
  port: portStart,
445
464
  portEnd,
446
465
  isBlock,
@@ -16,6 +16,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
16
16
  export const dynamic = 'force-dynamic';
17
17
 
18
18
  export async function POST(request: NextRequest): Promise<NextResponse> {
19
+ const denied = await requireAuthJWT(request);
20
+ if (denied) return denied;
19
21
  if (getIsDownloading()) {
20
22
  return NextResponse.json(
21
23
  { error: 'A download is already in progress' },