@flame0510/project-aether 1.2.0 → 1.4.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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -0,0 +1,510 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Browser access to an agent's Control UI, in its detail panel.
5
+ *
6
+ * From OpenClaw 9.x every new browser must be approved once before the Control UI
7
+ * connects. This section lists the requests waiting for approval and the browsers
8
+ * already approved, with approve, reject, rename and revoke. Agents created on
9
+ * 2026.7.x run with device approval disabled and have nothing to list.
10
+ *
11
+ * `OpenControlUiButton` opens the Control UI through a one-time link that pairs the
12
+ * browser with no approval, or through the plain token link when no one-time link can
13
+ * be issued (2026.7.x has no `openclaw dashboard --json`).
14
+ *
15
+ * Data comes from `/api/agents/[id]/devices`, which runs the OpenClaw CLI inside the
16
+ * container. It refreshes every few seconds only while this section is mounted.
17
+ */
18
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
19
+ import { Badge, Button, ConfirmModal, Input, RemoveButton } from '../components/ui';
20
+ import { SkeletonLines } from '../components/Skeleton';
21
+
22
+ interface PendingBrowser {
23
+ requestId: string;
24
+ deviceId: string;
25
+ displayName: string | null;
26
+ platform: string | null;
27
+ clientId: string | null;
28
+ clientMode: string | null;
29
+ roles: string[];
30
+ scopes: string[];
31
+ remoteIp: string | null;
32
+ browserOrigin: string | null;
33
+ isRepair: boolean;
34
+ requestedAtMs: number | null;
35
+ }
36
+
37
+ interface ApprovedBrowser {
38
+ deviceId: string;
39
+ label: string | null;
40
+ platform: string | null;
41
+ clientId: string | null;
42
+ clientMode: string | null;
43
+ roles: string[];
44
+ scopes: string[];
45
+ remoteIp: string | null;
46
+ approvedVia: string | null;
47
+ createdAtMs: number | null;
48
+ approvedAtMs: number | null;
49
+ lastUsedAtMs: number | null;
50
+ }
51
+
52
+ interface AccessState {
53
+ running: boolean;
54
+ requiresApproval: boolean | null;
55
+ pending: PendingBrowser[];
56
+ approved: ApprovedBrowser[];
57
+ }
58
+
59
+ interface InviteLink {
60
+ url: string;
61
+ /** The dashboard is reached on localhost: nobody else can use the link. */
62
+ loopbackHost: boolean;
63
+ }
64
+
65
+ const POLL_MS = 5_000;
66
+
67
+ const APPROVED_VIA: Record<string, string> = {
68
+ silent: 'automatically (local connection)',
69
+ owner: 'by an operator',
70
+ bootstrap: 'through a one-time link',
71
+ 'ssh-verified': 'SSH-verified',
72
+ 'trusted-proxy': 'by the trusted proxy',
73
+ 'trusted-cidr': 'from a trusted network',
74
+ };
75
+
76
+ function ago(ms: number | null): string {
77
+ if (!ms) return 'never';
78
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
79
+ if (s < 60) return `${s}s ago`;
80
+ const m = Math.round(s / 60);
81
+ if (m < 60) return `${m} min ago`;
82
+ const h = Math.round(m / 60);
83
+ if (h < 48) return `${h} h ago`;
84
+ return new Date(ms).toLocaleDateString();
85
+ }
86
+
87
+ function clientLabel(clientId: string | null): string {
88
+ if (clientId === 'openclaw-control-ui') return 'Control UI';
89
+ if (clientId === 'cli') return 'CLI';
90
+ return clientId ?? 'Unknown client';
91
+ }
92
+
93
+ function accessLabel(scopes: string[]): string {
94
+ if (scopes.includes('operator.admin')) return 'admin';
95
+ if (scopes.includes('operator.write')) return 'read & write';
96
+ return scopes.length ? 'read only' : 'no access';
97
+ }
98
+
99
+ const join = (parts: (string | null | false)[]) => parts.filter(Boolean).join(' · ');
100
+
101
+ const rowStyle: React.CSSProperties = {
102
+ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0',
103
+ borderTop: '1px solid var(--border)', flexWrap: 'wrap',
104
+ };
105
+ const titleStyle: React.CSSProperties = { fontSize: 10, color: 'var(--text-dim)', letterSpacing: 0.5, margin: '10px 0 2px' };
106
+ const metaStyle: React.CSSProperties = { fontSize: 11, color: 'var(--text-dim)', wordBreak: 'break-word' };
107
+
108
+ /**
109
+ * Opens the agent's Control UI in a new tab, inside the click.
110
+ *
111
+ * The tab goes straight to `/api/agents/[id]/open-control-ui`, which redirects to a
112
+ * one-time link or to the plain token link. Resolving the target server-side keeps
113
+ * this a plain navigation: no blank tab to fill in after an `await`, nothing for a
114
+ * popup blocker to stop.
115
+ */
116
+ export function OpenControlUiButton({
117
+ agentId,
118
+ label = 'Open ↗',
119
+ variant = 'primary',
120
+ stopPropagation = false,
121
+ }: {
122
+ agentId: string;
123
+ label?: string;
124
+ variant?: 'primary' | 'secondary';
125
+ /** For buttons inside a clickable row. */
126
+ stopPropagation?: boolean;
127
+ }) {
128
+ const open = (e: React.MouseEvent) => {
129
+ if (stopPropagation) e.stopPropagation();
130
+ window.open(`/api/agents/${encodeURIComponent(agentId)}/open-control-ui`, '_blank', 'noopener');
131
+ };
132
+
133
+ return (
134
+ <Button variant={variant} size="sm" onClick={open} title="Open the agent Control UI">
135
+ {label}
136
+ </Button>
137
+ );
138
+ }
139
+
140
+ export default function BrowserAccessSection({
141
+ agentId,
142
+ running,
143
+ onChanged,
144
+ }: {
145
+ agentId: string;
146
+ running: boolean;
147
+ /** After a successful approve, reject or revoke, so the list can refresh its badge. */
148
+ onChanged?: () => void;
149
+ }) {
150
+ const [access, setAccess] = useState<AccessState | null>(null);
151
+ const [loadError, setLoadError] = useState<string | null>(null);
152
+ const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
153
+ const [busy, setBusy] = useState<string | null>(null);
154
+ const busyRef = useRef<string | null>(null);
155
+ const [renaming, setRenaming] = useState<{ deviceId: string; name: string } | null>(null);
156
+ const [revoking, setRevoking] = useState<ApprovedBrowser | null>(null);
157
+ const abortRef = useRef<AbortController | null>(null);
158
+ const [invite, setInvite] = useState<InviteLink | null>(null);
159
+ const [copied, setCopied] = useState<boolean | null>(null);
160
+ const inviteAbortRef = useRef<AbortController | null>(null);
161
+ const inviteInputId = useId();
162
+
163
+ const load = useCallback(async () => {
164
+ abortRef.current?.abort();
165
+ const controller = new AbortController();
166
+ abortRef.current = controller;
167
+ try {
168
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/devices`, { signal: controller.signal });
169
+ if (abortRef.current !== controller) return;
170
+ if (!res.ok) {
171
+ const data = (await res.json().catch(() => null)) as { error?: string } | null;
172
+ setLoadError(data?.error ?? `Could not load browser access (HTTP ${res.status})`);
173
+ return;
174
+ }
175
+ const data = (await res.json()) as AccessState;
176
+ // A read that started before a mutation must not bring back what it removed.
177
+ if (abortRef.current !== controller) return;
178
+ setAccess(data);
179
+ setLoadError(null);
180
+ } catch (e) {
181
+ if ((e as Error)?.name !== 'AbortError' && abortRef.current === controller) {
182
+ setLoadError('Could not load browser access');
183
+ }
184
+ }
185
+ }, [agentId]);
186
+
187
+ useEffect(() => {
188
+ setAccess(null);
189
+ setLoadError(null);
190
+ setMsg(null);
191
+ setRenaming(null);
192
+ setInvite(null);
193
+ setCopied(null);
194
+ void load();
195
+ // One request at a time: `load()` runs the OpenClaw CLI inside the container and can
196
+ // take seconds on a slow agent, so the next tick waits for the previous response
197
+ // instead of an interval stacking calls on top of each other.
198
+ let alive = true;
199
+ let timer: ReturnType<typeof setTimeout> | null = null;
200
+ const tick = async () => {
201
+ await load();
202
+ if (alive) timer = setTimeout(() => { void tick(); }, POLL_MS);
203
+ };
204
+ if (running) timer = setTimeout(() => { void tick(); }, POLL_MS);
205
+ return () => {
206
+ alive = false;
207
+ if (timer) clearTimeout(timer);
208
+ abortRef.current?.abort();
209
+ inviteAbortRef.current?.abort();
210
+ };
211
+ }, [load, running]);
212
+
213
+ /**
214
+ * Runs one mutation at a time; refreshes afterwards without holding the lock.
215
+ * `applyLocally` updates the list as soon as the mutation succeeds, because the
216
+ * refresh runs the OpenClaw CLI and takes seconds.
217
+ */
218
+ const mutate = async (
219
+ key: string,
220
+ doneText: string,
221
+ request: () => Promise<Response>,
222
+ applyLocally?: (current: AccessState) => AccessState,
223
+ ): Promise<boolean> => {
224
+ if (busyRef.current) return false;
225
+ busyRef.current = key;
226
+ setBusy(key);
227
+ setMsg(null);
228
+ let ok = false;
229
+ try {
230
+ const res = await request();
231
+ if (res.ok) {
232
+ ok = true;
233
+ if (applyLocally) setAccess((current) => (current ? applyLocally(current) : current));
234
+ setMsg({ text: doneText, ok: true });
235
+ onChanged?.();
236
+ } else {
237
+ const data = (await res.json().catch(() => null)) as { error?: string } | null;
238
+ setMsg({ text: data?.error ?? `Request failed (HTTP ${res.status})`, ok: false });
239
+ }
240
+ } catch {
241
+ setMsg({ text: 'Request failed', ok: false });
242
+ }
243
+ busyRef.current = null;
244
+ setBusy(null);
245
+ void load();
246
+ return ok;
247
+ };
248
+
249
+ const base = `/api/agents/${encodeURIComponent(agentId)}/devices`;
250
+ const json = { 'Content-Type': 'application/json' };
251
+
252
+ const decide = (r: PendingBrowser, action: 'approve' | 'reject') =>
253
+ mutate(
254
+ `${action}:${r.requestId}`,
255
+ action === 'approve' ? 'Browser approved.' : 'Request rejected.',
256
+ () => fetch(base, { method: 'POST', headers: json, body: JSON.stringify({ action, requestId: r.requestId }) }),
257
+ (current) => ({ ...current, pending: current.pending.filter((p) => p.requestId !== r.requestId) }),
258
+ );
259
+
260
+ const saveRename = async () => {
261
+ if (!renaming) return;
262
+ const { deviceId, name } = renaming;
263
+ const saved = await mutate(
264
+ `rename:${deviceId}`,
265
+ 'Name saved.',
266
+ () => fetch(base, { method: 'PATCH', headers: json, body: JSON.stringify({ deviceId, name }) }),
267
+ (current) => ({
268
+ ...current,
269
+ approved: current.approved.map((d) => (d.deviceId === deviceId ? { ...d, label: name.trim() } : d)),
270
+ }),
271
+ );
272
+ if (saved) setRenaming(null);
273
+ };
274
+
275
+ const revoke = (d: ApprovedBrowser) =>
276
+ mutate(
277
+ `revoke:${d.deviceId}`,
278
+ 'Browser access revoked.',
279
+ () => fetch(`${base}?deviceId=${encodeURIComponent(d.deviceId)}`, { method: 'DELETE' }),
280
+ (current) => ({ ...current, approved: current.approved.filter((a) => a.deviceId !== d.deviceId) }),
281
+ );
282
+
283
+ const createInvite = async () => {
284
+ if (busyRef.current) return;
285
+ busyRef.current = 'invite';
286
+ setBusy('invite');
287
+ setMsg(null);
288
+ setCopied(null);
289
+ inviteAbortRef.current?.abort();
290
+ const controller = new AbortController();
291
+ inviteAbortRef.current = controller;
292
+ try {
293
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/invite-link`, { signal: controller.signal, cache: 'no-store' });
294
+ const data = (await res.json().catch(() => null)) as (InviteLink & { error?: string }) | null;
295
+ if (res.ok && data?.url) setInvite(data);
296
+ else setMsg({ text: data?.error ?? `Could not create the invite link (HTTP ${res.status})`, ok: false });
297
+ } catch (e) {
298
+ if ((e as Error)?.name !== 'AbortError') setMsg({ text: 'Could not create the invite link', ok: false });
299
+ }
300
+ busyRef.current = null;
301
+ setBusy(null);
302
+ };
303
+
304
+ /**
305
+ * The Clipboard API exists only in a secure context, and Rev4a is often reached over
306
+ * plain HTTP; the fallback copies the selected text of the link field.
307
+ */
308
+ const copyInvite = async () => {
309
+ if (!invite) return;
310
+ let ok = false;
311
+ if (window.isSecureContext && navigator.clipboard) {
312
+ try {
313
+ await navigator.clipboard.writeText(invite.url);
314
+ ok = true;
315
+ } catch {
316
+ // Fall back to the selection below.
317
+ }
318
+ }
319
+ if (!ok) {
320
+ const field = document.getElementById(inviteInputId) as HTMLInputElement | null;
321
+ if (field) {
322
+ field.focus();
323
+ field.select();
324
+ try {
325
+ ok = document.execCommand('copy');
326
+ } catch {
327
+ ok = false;
328
+ }
329
+ }
330
+ }
331
+ setCopied(ok);
332
+ };
333
+
334
+ if (!running) {
335
+ return <div style={metaStyle}>Start the agent to see and manage the browsers that can open its Control UI.</div>;
336
+ }
337
+
338
+ return (
339
+ <div>
340
+ {msg && (
341
+ <div style={{ margin: '6px 0' }}>
342
+ <Badge tone={msg.ok ? 'success' : 'danger'}><span style={{ fontSize: 10 }}>{msg.text}</span></Badge>
343
+ </div>
344
+ )}
345
+ {loadError && (
346
+ <div style={{ margin: '6px 0' }}>
347
+ <Badge tone="danger"><span style={{ fontSize: 10 }}>{loadError}</span></Badge>
348
+ </div>
349
+ )}
350
+
351
+ {access === null && !loadError && <SkeletonLines count={3} />}
352
+
353
+ {access?.requiresApproval === false && (
354
+ <div style={metaStyle}>
355
+ This agent runs with browser approval disabled (OpenClaw 2026.7.x): its Control UI opens with the token alone,
356
+ so there is nothing to approve.
357
+ </div>
358
+ )}
359
+
360
+ {access?.requiresApproval === true && (
361
+ <div style={{ margin: '8px 0' }}>
362
+ {!invite ? (
363
+ <Button variant="secondary" size="sm" loading={busy === 'invite'} disabled={busy !== null} onClick={() => { void createInvite(); }}>
364
+ {busy === 'invite' ? 'Creating…' : 'Invite link'}
365
+ </Button>
366
+ ) : (
367
+ <>
368
+ <div style={titleStyle}>INVITE LINK</div>
369
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
370
+ <div style={{ flex: '1 1 220px', minWidth: 0 }}>
371
+ <Input id={inviteInputId} aria-label="Invite link" readOnly value={invite.url} onFocus={(e) => e.currentTarget.select()} />
372
+ </div>
373
+ <Button variant="primary" size="sm" disabled={busy !== null} onClick={() => { void copyInvite(); }}>
374
+ {copied ? 'Copied' : 'Copy'}
375
+ </Button>
376
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => { setInvite(null); setCopied(null); }}>
377
+ Hide
378
+ </Button>
379
+ </div>
380
+ {copied === false && (
381
+ <div style={{ margin: '6px 0' }}>
382
+ <Badge tone="danger"><span style={{ fontSize: 10 }}>The browser blocked copying: the link is selected, press Ctrl+C or ⌘C.</span></Badge>
383
+ </div>
384
+ )}
385
+ {invite.loopbackHost && (
386
+ <div style={{ margin: '6px 0' }}>
387
+ <Badge tone="warning">
388
+ <span style={{ fontSize: 10 }}>This link uses localhost, which nobody else can reach. Open Rev4a on the address they will use and create the link again.</span>
389
+ </Badge>
390
+ </div>
391
+ )}
392
+ <div style={metaStyle}>
393
+ Send it to the person you want to let in. Their browser shows up under Waiting for approval, and nothing
394
+ opens until you approve it. Ask them to use a normal window: a private window forgets the approval when it closes.
395
+ </div>
396
+ <div style={metaStyle}>
397
+ The link carries the gateway token that every agent shares. To stop all links sent so far, change the
398
+ agents token: running agents restart with the new one.
399
+ </div>
400
+ </>
401
+ )}
402
+ </div>
403
+ )}
404
+
405
+ {access && access.requiresApproval !== false && (
406
+ <>
407
+ {access.pending.length > 0 && (
408
+ <>
409
+ <div style={titleStyle}>WAITING FOR APPROVAL ({access.pending.length})</div>
410
+ {access.pending.map((r) => {
411
+ const approveKey = `approve:${r.requestId}`;
412
+ const rejectKey = `reject:${r.requestId}`;
413
+ return (
414
+ <div key={r.requestId} style={rowStyle}>
415
+ <div style={{ flex: '1 1 220px', minWidth: 0 }}>
416
+ <div style={{ fontSize: 12, color: 'var(--text)' }}>
417
+ {join([r.displayName ?? clientLabel(r.clientId), r.platform])}
418
+ </div>
419
+ <div style={metaStyle}>
420
+ {join([r.remoteIp, r.browserOrigin, `asked ${ago(r.requestedAtMs)}`, accessLabel(r.scopes), r.isRepair && 'asks for broader access'])}
421
+ </div>
422
+ </div>
423
+ <Button variant="success" size="sm" loading={busy === approveKey} disabled={busy !== null} onClick={() => { void decide(r, 'approve'); }}>
424
+ {busy === approveKey ? 'Approving…' : 'Approve'}
425
+ </Button>
426
+ <Button variant="secondary" size="sm" loading={busy === rejectKey} disabled={busy !== null} onClick={() => { void decide(r, 'reject'); }}>
427
+ {busy === rejectKey ? 'Rejecting…' : 'Reject'}
428
+ </Button>
429
+ </div>
430
+ );
431
+ })}
432
+ </>
433
+ )}
434
+
435
+ <div style={titleStyle}>APPROVED BROWSERS ({access.approved.length})</div>
436
+ {access.approved.length === 0 && <div style={metaStyle}>No browser has been approved yet.</div>}
437
+ {access.approved.map((d) => {
438
+ const name = d.label ?? `${clientLabel(d.clientId)} ${d.deviceId.slice(0, 8)}`;
439
+ const renameKey = `rename:${d.deviceId}`;
440
+ const revokeKey = `revoke:${d.deviceId}`;
441
+ const isRenaming = renaming?.deviceId === d.deviceId;
442
+ return (
443
+ <div key={d.deviceId} style={{ ...rowStyle, opacity: busy === revokeKey ? 0.6 : 1 }}>
444
+ <div style={{ flex: '1 1 220px', minWidth: 0 }}>
445
+ {isRenaming ? (
446
+ <Input
447
+ aria-label={`New name for ${name}`}
448
+ value={renaming.name}
449
+ maxLength={64}
450
+ autoFocus
451
+ onChange={(e) => setRenaming({ deviceId: d.deviceId, name: e.target.value })}
452
+ onKeyDown={(e) => {
453
+ if (e.key === 'Enter') void saveRename();
454
+ if (e.key === 'Escape') setRenaming(null);
455
+ }}
456
+ />
457
+ ) : (
458
+ <div style={{ fontSize: 12, color: 'var(--text)' }}>{name}</div>
459
+ )}
460
+ <div style={metaStyle}>
461
+ {join([clientLabel(d.clientId), d.platform, d.remoteIp, accessLabel(d.scopes)])}
462
+ </div>
463
+ <div style={metaStyle}>
464
+ {join([
465
+ `approved ${d.approvedVia ? APPROVED_VIA[d.approvedVia] ?? d.approvedVia : ''} ${ago(d.approvedAtMs ?? d.createdAtMs)}`.replace(/\s+/g, ' '),
466
+ `last used ${ago(d.lastUsedAtMs)}`,
467
+ busy === revokeKey && 'Revoking…',
468
+ ])}
469
+ </div>
470
+ </div>
471
+ {isRenaming ? (
472
+ <>
473
+ <Button variant="primary" size="sm" loading={busy === renameKey} disabled={busy !== null || !renaming.name.trim()} onClick={() => { void saveRename(); }}>
474
+ {busy === renameKey ? 'Saving…' : 'Save'}
475
+ </Button>
476
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => setRenaming(null)}>
477
+ Cancel
478
+ </Button>
479
+ </>
480
+ ) : (
481
+ <>
482
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => setRenaming({ deviceId: d.deviceId, name: d.label ?? '' })}>
483
+ Rename
484
+ </Button>
485
+ <RemoveButton label={name} title={`Revoke access for ${name}`} disabled={busy !== null} onClick={() => setRevoking(d)} />
486
+ </>
487
+ )}
488
+ </div>
489
+ );
490
+ })}
491
+ </>
492
+ )}
493
+
494
+ <ConfirmModal
495
+ open={revoking !== null}
496
+ onClose={() => setRevoking(null)}
497
+ onConfirm={() => {
498
+ const target = revoking;
499
+ setRevoking(null);
500
+ if (target) void revoke(target);
501
+ }}
502
+ title={`Revoke ${revoking?.label ?? 'this browser'}?`}
503
+ message="This browser loses access to the Control UI and needs approval again the next time it connects."
504
+ confirmLabel="Revoke"
505
+ cancelLabel="Cancel"
506
+ tone="danger"
507
+ />
508
+ </div>
509
+ );
510
+ }
@@ -27,14 +27,15 @@ interface ChannelsData {
27
27
 
28
28
  interface Props {
29
29
  agentId: string;
30
- agentName: string;
30
+ /** What to call the agent in the UI: its display name, falling back to the container name. */
31
+ displayName: string;
31
32
  open: boolean;
32
33
  onClose: () => void;
33
34
  }
34
35
 
35
36
  const PAIRING_POLL_MS = 5000;
36
37
 
37
- export default function ChannelManager({ agentId, agentName, open, onClose }: Props) {
38
+ export default function ChannelManager({ agentId, displayName, open, onClose }: Props) {
38
39
  const [channels, setChannels] = useState<ChannelsData | null>(null);
39
40
  const [channelsLoading, setChannelsLoading] = useState(true);
40
41
  const [pairingsLoading, setPairingsLoading] = useState(true);
@@ -51,7 +52,7 @@ export default function ChannelManager({ agentId, agentName, open, onClose }: Pr
51
52
 
52
53
  const [pending, setPending] = useState<PendingPairing[]>([]);
53
54
  const [approved, setApproved] = useState<ApprovedPairing[]>([]);
54
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
55
+ const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55
56
  const pairingsAbortRef = useRef<AbortController | null>(null);
56
57
  const channelsAbortRef = useRef<AbortController | null>(null);
57
58
 
@@ -120,14 +121,21 @@ export default function ChannelManager({ agentId, agentName, open, onClose }: Pr
120
121
  }
121
122
  }, [channelsLoading, channels?.telegram?.connected, fetchPairings]);
122
123
 
123
- // Poll pairings when on the active tab and connected/linked
124
+ // Poll pairings when on the active tab and connected/linked. One request at a time:
125
+ // each reads the OpenClaw pairing store inside the container and can take seconds on a
126
+ // slow agent, so the next tick waits for the previous response instead of stacking.
124
127
  useEffect(() => {
125
- if (open && channels?.telegram?.connected) {
126
- pollRef.current = setInterval(pollPairings, PAIRING_POLL_MS);
127
- } else {
128
- if (pollRef.current) clearInterval(pollRef.current);
129
- }
130
- return () => { if (pollRef.current) clearInterval(pollRef.current); };
128
+ if (!open || !channels?.telegram?.connected) return;
129
+ let alive = true;
130
+ const tick = async () => {
131
+ await pollPairings();
132
+ if (alive) pollRef.current = setTimeout(() => { void tick(); }, PAIRING_POLL_MS);
133
+ };
134
+ pollRef.current = setTimeout(() => { void tick(); }, PAIRING_POLL_MS);
135
+ return () => {
136
+ alive = false;
137
+ if (pollRef.current) clearTimeout(pollRef.current);
138
+ };
131
139
  }, [open, channels?.telegram?.connected, pollPairings]);
132
140
 
133
141
  const handleTelegramConnect = async () => {
@@ -232,7 +240,7 @@ export default function ChannelManager({ agentId, agentName, open, onClose }: Pr
232
240
  const tg = channels?.telegram;
233
241
 
234
242
  return (
235
- <Modal open={open} onClose={onClose} title={`Channel Manager: ${agentName}`} maxWidth={520}>
243
+ <Modal open={open} onClose={onClose} title={`Channel Manager: ${displayName}`} maxWidth={520}>
236
244
 
237
245
  {error && <div style={{ margin: '8px 0' }}><Badge tone="danger">{error}</Badge></div>}
238
246
  {success && <div style={{ margin: '8px 0' }}><Badge tone="success">{success}</Badge></div>}