@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
@@ -7,6 +7,10 @@ import { Button, ListItem, Badge, ConfirmModal, Input, type Tone } from '../comp
7
7
  import PasswordInput from '../components/PasswordInput';
8
8
  import ImageDownloadBanner from './ImageDownloadBanner';
9
9
  import ChannelManager from './ChannelManager';
10
+ import ModelSection from './ModelSection';
11
+ import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
12
+ import UpdateSection from './UpdateSection';
13
+ import { validatePortInput } from '@/lib/agent-ports';
10
14
  import {
11
15
  InfoIcon, BracesIcon, KeyIcon, TrashIcon, TerminalIcon,
12
16
  LinkIcon, ArchiveIcon, RotateIcon, PackageIcon,
@@ -43,27 +47,10 @@ interface Agent {
43
47
  created: string | null;
44
48
  env: string[];
45
49
  controlPort: string | null;
46
- authToken: string | null;
47
- imageOutdated?: boolean;
48
- }
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;
50
+ /** OpenClaw version the agent runs; null when it cannot be read (stopped agent on an unlabelled image). */
51
+ openclawVersion?: string | null;
52
+ /** A newer supported OpenClaw version is downloaded. */
53
+ updateAvailable?: boolean;
67
54
  }
68
55
 
69
56
  type FilterState = 'all' | 'running' | 'exited';
@@ -174,6 +161,10 @@ function AgentListItem({
174
161
  loading,
175
162
  selected,
176
163
  channelSummary,
164
+ modelSummary,
165
+ deviceSummary,
166
+ backupSummary,
167
+ busy,
177
168
  onClick,
178
169
  onLifecycle,
179
170
  }: {
@@ -181,6 +172,14 @@ function AgentListItem({
181
172
  loading: boolean;
182
173
  selected?: boolean;
183
174
  channelSummary: Record<string, { telegram: boolean }>;
175
+ modelSummary: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>;
176
+ /** Browsers waiting for approval, per agent. */
177
+ deviceSummary: Record<string, { pending: number }>;
178
+ /** Cold backup currently running, per agent (chip on the card). */
179
+ /** The long operation in progress, per agent (chip on the card). */
180
+ backupSummary: Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }>;
181
+ /** Any long operation running on this agent: the lifecycle toggle is disabled. */
182
+ busy?: boolean;
184
183
  onClick: () => void;
185
184
  onLifecycle: () => void;
186
185
  }) {
@@ -189,7 +188,7 @@ function AgentListItem({
189
188
  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
190
189
  <ToggleSwitch
191
190
  checked={agent.state === 'running'}
192
- disabled={loading || agent.state === 'unknown'}
191
+ disabled={loading || agent.state === 'unknown' || !!busy}
193
192
  onChange={() => { void onLifecycle(); }}
194
193
  ariaLabel={agent.state === 'running' ? 'Stop container' : 'Start container'}
195
194
  />
@@ -200,20 +199,7 @@ function AgentListItem({
200
199
  <div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
201
200
  {loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
202
201
  {agent.state === 'running' && agent.controlPort && (
203
- <Button
204
- variant="primary"
205
- size="sm"
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
- }}
213
- title="Open agent Control UI"
214
- >
215
- Open ↗
216
- </Button>
202
+ <OpenControlUiButton agentId={agent.agentId} stopPropagation />
217
203
  )}
218
204
  </div>
219
205
  </div>
@@ -221,16 +207,77 @@ function AgentListItem({
221
207
  {agent.image?.includes('/') ? agent.image.split('/').pop() : agent.image}
222
208
  {agent.ports && <span style={{ marginLeft: 8 }}>{agent.ports}</span>}
223
209
  </div>
210
+ {(() => {
211
+ const m = modelSummary[agent.agentId];
212
+ if (!m || !m.primary) return null;
213
+ // Tone follows what the operator has to do about it: red means the agent
214
+ // will fail on its next turn, amber means it still works on a retired name.
215
+ // The red states differ in remedy — pick another model for the first two,
216
+ // run Sync All on the Gateway page for OUT OF SYNC.
217
+ const failing = m.health === 'missing' || m.health === 'disabled' || m.health === 'out-of-sync';
218
+ const tone = failing ? 'danger' : m.health === 'deprecated' ? 'warning' : undefined;
219
+ const chip = m.health === 'missing' ? 'NOT IN CATALOGUE'
220
+ : m.health === 'disabled' ? 'NOT ENABLED'
221
+ : m.health === 'out-of-sync' ? 'OUT OF SYNC'
222
+ : m.health === 'deprecated' ? 'DEPRECATED'
223
+ : m.fallbackCount === 0 ? 'NO FALLBACK' : null;
224
+ return (
225
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4, flexWrap: 'wrap' }}>
226
+ <span style={{ fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
227
+ {m.primary.replace(/^rev4a\//, '')}
228
+ {/* Only the first fallback: a list here would drown the row, and the
229
+ full set is editable in the detail panel anyway. */}
230
+ {m.firstFallback && (
231
+ <span style={{ color: '#666' }}>
232
+ {' ↳ '}{m.firstFallback.replace(/^rev4a\//, '')}
233
+ {m.fallbackCount > 1 && ` +${m.fallbackCount - 1}`}
234
+ </span>
235
+ )}
236
+ </span>
237
+ {chip && (
238
+ <Badge tone={(tone ?? 'neutral') as Tone}>
239
+ <span style={{ fontSize: 8, padding: '1px 6px' }}>{chip}</span>
240
+ </Badge>
241
+ )}
242
+ </div>
243
+ );
244
+ })()}
224
245
  {channelSummary[agent.agentId]?.telegram && (
225
246
  <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
226
247
  <Badge tone="success"><span style={{ fontSize: 8, padding: '1px 6px' }}>TG</span></Badge>
227
248
  </div>
228
249
  )}
229
- {agent.imageOutdated && (
250
+ {(deviceSummary[agent.agentId]?.pending ?? 0) > 0 && (
230
251
  <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
231
- <Badge tone="warning"><span style={{ fontSize: 8, padding: '1px 6px' }}>OUTDATED</span></Badge>
252
+ <Badge tone="warning">
253
+ <span style={{ fontSize: 8, padding: '1px 6px' }}>
254
+ {deviceSummary[agent.agentId].pending === 1 ? 'BROWSER WAITING' : `${deviceSummary[agent.agentId].pending} BROWSERS WAITING`}
255
+ </span>
256
+ </Badge>
232
257
  </div>
233
258
  )}
259
+ {agent.updateAvailable && (
260
+ <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
261
+ <Badge tone="warning"><span style={{ fontSize: 8, padding: '1px 6px' }}>UPDATE AVAILABLE</span></Badge>
262
+ </div>
263
+ )}
264
+ {(() => {
265
+ const b = backupSummary[agent.agentId];
266
+ if (!b) return null;
267
+ const label =
268
+ b.kind === 'backup' ? (b.percent !== null && b.percent !== undefined ? `BACKUP ${b.percent}%` : 'BACKUP…')
269
+ : b.kind === 'restore' ? 'RESTORING'
270
+ : b.kind === 'recreate' ? 'RECREATING'
271
+ : b.kind === 'edit' ? 'EDITING'
272
+ : 'UPDATING';
273
+ return (
274
+ <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
275
+ <Badge tone="neutral">
276
+ <span style={{ fontSize: 8, padding: '1px 6px' }}>{label}</span>
277
+ </Badge>
278
+ </div>
279
+ );
280
+ })()}
234
281
  </ListItem>
235
282
  );
236
283
  }
@@ -245,6 +292,9 @@ export default function AgentsPageClient() {
245
292
  const [isMobile, setIsMobile] = useState(false);
246
293
  const [showList, setShowList] = useState(true);
247
294
  const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
295
+ const [mdSummary, setMdSummary] = useState<Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>>({});
296
+ const [dvSummary, setDvSummary] = useState<Record<string, { pending: number }>>({});
297
+ const [bkSummary, setBkSummary] = useState<Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }>>({});
248
298
  const [imageReady, setImageReady] = useState(true);
249
299
  // null = not checked yet. Keeping "unknown" distinct from "none configured" is
250
300
  // the whole point: `false` as the initial value makes an unanswered check look
@@ -258,6 +308,80 @@ export default function AgentsPageClient() {
258
308
  const skipNextPoll = useRef(false);
259
309
  /** Guards the un-awaited channel-summary fetch against out-of-order responses. */
260
310
  const chSummarySeq = useRef(0);
311
+ const mdSummarySeq = useRef(0);
312
+ const dvSummarySeq = useRef(0);
313
+ const bkSummarySeq = useRef(0);
314
+
315
+ /**
316
+ * Refresh the per-agent model rows that feed the card chips.
317
+ *
318
+ * Shared by the 15s poll and by a save in the detail panel: without the second
319
+ * caller the chip kept showing the previous model until the next poll, which
320
+ * reads as "the save did not work".
321
+ *
322
+ * Sequence-guarded because several can overlap; the last one issued wins.
323
+ */
324
+ const refreshModelSummary = useCallback(async () => {
325
+ const seq = ++mdSummarySeq.current;
326
+ try {
327
+ const res = await fetch('/api/agents/models-summary');
328
+ if (!res.ok || seq !== mdSummarySeq.current) return;
329
+ const payload = await res.json();
330
+ const byId: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }> = {};
331
+ for (const row of payload.agents ?? []) byId[row.agentId] = row;
332
+ setMdSummary(byId);
333
+ } catch { /* badges are optional */ }
334
+ }, []);
335
+
336
+ /**
337
+ * Refresh the "browser waiting" badges. Shared by the poll and by an approve,
338
+ * reject or revoke in the detail panel, and sequence-guarded like the model rows.
339
+ */
340
+ const refreshDeviceSummary = useCallback(async () => {
341
+ const seq = ++dvSummarySeq.current;
342
+ try {
343
+ const res = await fetch('/api/agents/devices-summary');
344
+ if (!res.ok || seq !== dvSummarySeq.current) return;
345
+ const payload = await res.json();
346
+ const byId: Record<string, { pending: number }> = {};
347
+ for (const row of payload.agents ?? []) byId[row.agentId] = { pending: row.pending ?? 0 };
348
+ setDvSummary(byId);
349
+ } catch { /* badges are optional */ }
350
+ }, []);
351
+
352
+ /**
353
+ * Refresh the activity chips (`BACKUP nn%`, `RESTORING`, `RECREATING`, `EDITING`, `UPDATING`)
354
+ * from `/api/agents/activity-summary`. Sequence-guarded like the other summaries.
355
+ */
356
+ const refreshBackupSummary = useCallback(async () => {
357
+ const seq = ++bkSummarySeq.current;
358
+ try {
359
+ const res = await fetch('/api/agents/activity-summary');
360
+ if (!res.ok || seq !== bkSummarySeq.current) return;
361
+ const payload = await res.json();
362
+ const byId: Record<string, { kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit'; file?: string | null; version?: string | null; percent?: number | null }> = {};
363
+ for (const row of payload.agents ?? []) byId[row.agentId] = row;
364
+ setBkSummary(byId);
365
+ } catch { /* badges are optional */ }
366
+ }, []);
367
+
368
+ /**
369
+ * A long job just started from the detail panel. Shows its chip at once instead of
370
+ * waiting for the next list poll, then lets `refreshBackupSummary()` confirm it.
371
+ */
372
+ const notifyActivity = useCallback((agentId: string, kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit') => {
373
+ setBkSummary((prev) => ({ ...prev, [agentId]: { kind } }));
374
+ void refreshBackupSummary();
375
+ }, [refreshBackupSummary]);
376
+
377
+ // While any backup runs the chips follow the live percent at 2s; when none
378
+ // runs the dedicated timer stops and the summary only rides the 15s poll.
379
+ const anyBackupRunning = Object.keys(bkSummary).length > 0;
380
+ useEffect(() => {
381
+ if (!anyBackupRunning) return;
382
+ const i = setInterval(() => { void refreshBackupSummary(); }, 2000);
383
+ return () => clearInterval(i);
384
+ }, [anyBackupRunning, refreshBackupSummary]);
261
385
  useEffect(() => {
262
386
  const check = () => setIsMobile(window.innerWidth < 768);
263
387
  check();
@@ -290,6 +414,12 @@ export default function AgentsPageClient() {
290
414
  setChSummary(await chRes.json());
291
415
  } catch { /* badges are optional */ }
292
416
  })();
417
+
418
+ // Same treatment for the model summary: it decorates rows, so it must not
419
+ // hold the list, and a slow earlier response must not overwrite a newer one.
420
+ void refreshModelSummary();
421
+ void refreshDeviceSummary();
422
+ void refreshBackupSummary();
293
423
  } catch (e: any) {
294
424
  setError(e.message || 'Loading error');
295
425
  } finally {
@@ -402,7 +532,7 @@ export default function AgentsPageClient() {
402
532
  }, [agents, filter]);
403
533
 
404
534
  // 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
535
+ // A snapshot goes stale on every 15s poll (status, ports, version) and
406
536
  // survives deletion; deriving means a rename shows up on the next load() and a
407
537
  // deleted agent can't leave a detail panel pointing at something that's gone.
408
538
  const selected = useMemo(
@@ -511,13 +641,13 @@ export default function AgentsPageClient() {
511
641
  {providersConfigured === false && <NoProviderBanner />}
512
642
  {/* Scrollable content */}
513
643
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
514
- <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
644
+ <DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
515
645
  </div>
516
646
  </div>
517
647
  );
518
648
  }
519
649
 
520
- // Mobile: lista
650
+ // Mobile: list
521
651
  return (
522
652
  <div style={{ flex: 1, background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', }}>
523
653
  <div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -545,6 +675,10 @@ export default function AgentsPageClient() {
545
675
  agent={agent}
546
676
  loading={lifecycleLoading === agent.agentId}
547
677
  channelSummary={chSummary}
678
+ modelSummary={mdSummary}
679
+ deviceSummary={dvSummary}
680
+ backupSummary={bkSummary}
681
+ busy={!!bkSummary[agent.agentId]}
548
682
  onClick={() => selectAgent(agent)}
549
683
  onLifecycle={() => handleLifecycle(agent)}
550
684
  />
@@ -590,6 +724,10 @@ export default function AgentsPageClient() {
590
724
  agent={agent}
591
725
  loading={lifecycleLoading === agent.agentId}
592
726
  channelSummary={chSummary}
727
+ modelSummary={mdSummary}
728
+ deviceSummary={dvSummary}
729
+ backupSummary={bkSummary}
730
+ busy={!!bkSummary[agent.agentId]}
593
731
  selected={isSelected}
594
732
  onClick={() => setSelectedId(agent.agentId)}
595
733
  onLifecycle={() => handleLifecycle(agent)}
@@ -604,7 +742,7 @@ export default function AgentsPageClient() {
604
742
  // key: without it React reuses one DetailPanel instance across
605
743
  // selections, so in-flight state (deleting, recreating, restarting,
606
744
  // toasts, open confirms) leaks onto the next agent you click.
607
- <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
745
+ <DetailPanel key={selected.agentId} agent={selected} busy={!!bkSummary[selected.agentId]} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} onAccessChanged={refreshDeviceSummary} onActivity={notifyActivity} />
608
746
  )}
609
747
  </div>
610
748
  </div>
@@ -613,37 +751,79 @@ export default function AgentsPageClient() {
613
751
  }
614
752
 
615
753
  // ─── Edit Agent modal ───
616
- function EditAgentModal({ agent, onClose, onSaved }: {
754
+
755
+ /** The host range an agent publishes for its gateway (container ports 3000+), e.g. "3710-3719". */
756
+ function currentPortRange(ports: string): string {
757
+ const hosts = (ports || '')
758
+ .split(',')
759
+ .map((p) => p.trim())
760
+ .filter(Boolean)
761
+ .map((p) => {
762
+ const [host, container] = p.split(':');
763
+ const c = parseInt((container || '').replace(/\/.*$/, ''), 10);
764
+ return Number.isInteger(c) && c >= 3000 ? parseInt(host, 10) : NaN;
765
+ })
766
+ .filter((n): n is number => Number.isInteger(n));
767
+ if (hosts.length === 0) return '';
768
+ const min = Math.min(...hosts);
769
+ const max = Math.max(...hosts);
770
+ return min === max ? `${min}` : `${min}-${max}`;
771
+ }
772
+
773
+ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
617
774
  agent: Agent;
618
775
  onClose: () => void;
619
776
  /** Rename succeeded — the parent refetches so the new name propagates. */
620
777
  onSaved: () => void;
778
+ /** The edit job started — show the EDITING chip at once. */
779
+ onStarted?: () => void;
621
780
  }) {
622
- const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
781
+ const initialName = agent.displayName || agent.name;
782
+ const initialPorts = currentPortRange(agent.ports);
783
+ const [displayName, setDisplayName] = useState(initialName);
784
+ const [portRange, setPortRange] = useState(initialPorts);
623
785
  const [saving, setSaving] = useState(false);
624
786
  const [error, setError] = useState('');
625
787
 
788
+ // Client-side shape checks (the same messages as the API). Whether a port is already
789
+ // published by another agent is checked by the server, which knows every container
790
+ // and excludes this agent's own block.
791
+ const nameError = displayName.trim() ? '' : 'Display name must not be empty';
792
+ const portError = useMemo(() => {
793
+ if (!portRange.trim()) return '';
794
+ const r = validatePortInput(portRange);
795
+ return r.valid ? '' : r.error;
796
+ }, [portRange]);
797
+
798
+ const nameChanged = displayName.trim() !== initialName;
799
+ const portsChanged = portRange.trim() !== initialPorts;
800
+ const nothingChanged = !nameChanged && !portsChanged;
801
+
626
802
  const handleSave = async () => {
627
- const trimmed = displayName.trim();
628
- if (!trimmed) {
629
- setError('Display name must not be empty');
803
+ if (nothingChanged) {
804
+ setError('Nothing to change');
630
805
  return;
631
806
  }
632
- if (trimmed === (agent.displayName || agent.name)) {
633
- onClose();
807
+ if (nameError || portError) {
808
+ setError('');
634
809
  return;
635
810
  }
636
811
  setSaving(true);
637
812
  setError('');
638
813
  try {
814
+ const body: { displayName?: string; portRange?: string } = {};
815
+ if (nameChanged) body.displayName = displayName.trim();
816
+ if (portsChanged) body.portRange = portRange.trim();
639
817
  const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, {
640
818
  method: 'PATCH',
641
819
  headers: { 'Content-Type': 'application/json' },
642
- body: JSON.stringify({ displayName: trimmed }),
820
+ body: JSON.stringify(body),
643
821
  });
644
822
  const data = await res.json();
645
823
  if (!res.ok) throw new Error(data.error || 'Failed to update');
646
- // Refetch the list so the new display name propagates — no full reload.
824
+ // The job is async (202): show the chip now, and refetch the list so the new
825
+ // display name propagates once the rebuild finishes.
826
+ onStarted?.();
647
827
  onSaved();
648
828
  onClose();
649
829
  } catch (e: unknown) {
@@ -679,13 +859,27 @@ function EditAgentModal({ agent, onClose, onSaved }: {
679
859
  value={displayName}
680
860
  onChange={(e) => { setDisplayName(e.target.value); setError(''); }}
681
861
  placeholder="My Agent 🚀"
682
- error={error}
862
+ error={nameError}
863
+ disabled={saving}
864
+ />
865
+ </div>
866
+ <div>
867
+ <Input
868
+ label="Host Port"
869
+ value={portRange}
870
+ onChange={(e) => { setPortRange(e.target.value.replace(/[^0-9-]/g, '').slice(0, 11)); setError(''); }}
871
+ placeholder="3700-3709"
872
+ error={portError}
683
873
  disabled={saving}
684
874
  />
685
875
  <div style={{ fontSize: 10, color: '#666', marginTop: 4 }}>
686
- Container: {agent.name} · This will recreate the container. Sessions and workspace are preserved.
876
+ Port range (e.g. 3700-3709) or single port (e.g. 3700). Maps onto the container's port 3000; the Control UI URL changes with it.
687
877
  </div>
688
878
  </div>
879
+ <div style={{ fontSize: 10, color: '#666' }}>
880
+ Container: {agent.name} · This recreates the container without a backup. Sessions and workspace are preserved.
881
+ </div>
882
+ {error && <div style={{ fontSize: 11, color: '#ef4444' }}>{error}</div>}
689
883
  <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
690
884
  <Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
691
885
  <Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
@@ -768,23 +962,106 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
768
962
  }
769
963
 
770
964
  // ─── Detail panel (shared between mobile & desktop) ───
771
- function DetailPanel({ agent, onDeleted, onChanged }: {
965
+ /* ─── Edit banner - shown while a rename/port change rebuilds the container ─── */
966
+
967
+ interface EditJob {
968
+ status: 'rebuilding' | 'done' | 'failed' | 'interrupted';
969
+ displayName: string | null;
970
+ portRange: string | null;
971
+ error: string | null;
972
+ }
973
+
974
+ /**
975
+ * Visible while `PATCH /api/agents/[id]` is rebuilding the container, and resumed on
976
+ * mount so a reload or a navigation shows the running edit instead of allowing a second.
977
+ * The server answers 409 to any new edit anyway.
978
+ */
979
+ function EditBanner({ agentId, onChanged, ping }: { agentId: string; onChanged: () => void; ping: number }) {
980
+ const [job, setJob] = useState<EditJob | null>(null);
981
+ const abortRef = useRef<AbortController | null>(null);
982
+ // Held in a ref: calling it must not re-run the effect (the parent may hand a new one
983
+ // every render), which would restart the poll on every render.
984
+ const onChangedRef = useRef(onChanged);
985
+ onChangedRef.current = onChanged;
986
+
987
+ useEffect(() => {
988
+ let alive = true;
989
+ let shown = false;
990
+ const controller = new AbortController();
991
+ abortRef.current = controller;
992
+ const tick = async () => {
993
+ try {
994
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}`, { signal: controller.signal });
995
+ if (!res.ok) return;
996
+ const data = await res.json();
997
+ const cur: EditJob | null = data.edit ?? null;
998
+ if (cur?.status === 'rebuilding') { shown = true; setJob(cur); return; }
999
+ if (shown) {
1000
+ shown = false;
1001
+ setJob(null);
1002
+ if (cur && (cur.status === 'done' || cur.status === 'failed')) onChangedRef.current();
1003
+ }
1004
+ } catch { /* aborted or transient — the next tick retries */ }
1005
+ };
1006
+ // `ping` changes when an edit is started here, so the banner appears at once; the
1007
+ // 5 s poll then keeps it fresh and clears it when the job finishes.
1008
+ void tick();
1009
+ const timer = setInterval(() => { if (alive) void tick(); }, 5000);
1010
+ return () => { alive = false; clearInterval(timer); controller.abort(); };
1011
+ }, [agentId, ping]);
1012
+
1013
+ if (!job) return null;
1014
+ return (
1015
+ <div style={{
1016
+ padding: '8px 12px',
1017
+ fontSize: 11,
1018
+ fontFamily: 'var(--font-mono)',
1019
+ color: 'var(--violet)',
1020
+ background: 'rgba(146,91,252,0.08)',
1021
+ border: '1px solid rgba(146,91,252,0.25)',
1022
+ display: 'flex',
1023
+ alignItems: 'center',
1024
+ gap: 8,
1025
+ }}>
1026
+ <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: 'var(--violet)', animation: 'pulse 1s infinite', flexShrink: 0 }} />
1027
+ <span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
1028
+ Rebuilding the container{job.displayName ? ` as ${job.displayName}` : ''}
1029
+ <span style={{ whiteSpace: 'nowrap' }}> (the agent restarts when done)</span>
1030
+ </span>
1031
+ </div>
1032
+ );
1033
+ }
1034
+
1035
+ function DetailPanel({ agent, busy, onDeleted, onChanged, onModelSaved, onAccessChanged, onActivity }: {
772
1036
  agent: Agent;
773
1037
  /** The agent is gone — the parent drops it from the list. */
774
1038
  onDeleted: (agentId: string) => void;
775
1039
  /** The agent changed in place (renamed, recreated) — refetch the list. */
776
1040
  onChanged: () => void;
1041
+ /**
1042
+ * The model changed. Narrower than `onChanged` on purpose: only the card chip
1043
+ * needs refreshing, so this avoids refetching the whole list plus the channel
1044
+ * summary for a field the parent already knows how to reload on its own.
1045
+ */
1046
+ onModelSaved: () => void;
1047
+ /** A browser was approved, rejected or revoked — refresh the list badges. */
1048
+ onAccessChanged: () => void;
1049
+ /** A long job started here — show its chip at once (page-level notifyActivity). */
1050
+ onActivity: (agentId: string, kind: 'backup' | 'restore' | 'recreate' | 'update' | 'edit') => void;
1051
+ /** This agent has a long operation running: its actions are disabled. */
1052
+ busy: boolean;
777
1053
  }) {
778
1054
  const [deleting, setDeleting] = useState(false);
779
1055
  const [deleteError, setDeleteError] = useState('');
780
1056
  const [showConfig, setShowConfig] = useState(false);
781
1057
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
782
1058
  const [showEdit, setShowEdit] = useState(false);
1059
+ /** Bumped when an edit starts here, so the banner refetches at once. */
1060
+ const [editPing, setEditPing] = useState(0);
783
1061
 
784
1062
  // Safe to resolve during render: this panel only mounts once the agent list has
785
1063
  // been fetched client-side, so it never runs in a server render. The helper
786
1064
  // guards for a missing `window` regardless.
787
- const controlUrl = agentControlUrl(agent);
788
1065
 
789
1066
  const handleDelete = async () => {
790
1067
  setDeleting(true);
@@ -816,7 +1093,7 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
816
1093
  <div>
817
1094
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
818
1095
  <span style={{ fontSize: 16, fontWeight: 500 }}>{agent.displayName || agent.name}</span>
819
- <Button variant="primary" size="sm" onClick={() => setShowEdit(true)} title="Edit agent settings">
1096
+ <Button variant="primary" size="sm" disabled={busy} onClick={() => setShowEdit(true)} title={busy ? 'An operation is running on this agent' : 'Edit agent settings'}>
820
1097
  ✎
821
1098
  </Button>
822
1099
  </div>
@@ -831,6 +1108,9 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
831
1108
  </div>
832
1109
  </div>
833
1110
 
1111
+ {/* Rename / ports rebuild, resumed on mount */}
1112
+ <EditBanner agentId={agent.agentId} onChanged={onChanged} ping={editPing} />
1113
+
834
1114
  {/* Properties */}
835
1115
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
836
1116
  <SectionHeader icon={<InfoIcon size={12} />} label="PROPERTIES" />
@@ -840,6 +1120,7 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
840
1120
  ['Container', agent.name],
841
1121
  ['Image', agent.image],
842
1122
  ['Tag', agent.imageTag],
1123
+ ['OpenClaw', agent.openclawVersion || 'unknown'],
843
1124
  ['Template', agent.template || '-'],
844
1125
  ['State', agent.state],
845
1126
  ['Ports', agent.ports || '-'],
@@ -854,7 +1135,26 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
854
1135
  </div>
855
1136
 
856
1137
  {/* Channels - Telegram */}
857
- <ChannelSection agentId={agent.agentId} agentName={agent.name} />
1138
+ <ChannelSection agentId={agent.agentId} displayName={agent.displayName || agent.name} />
1139
+
1140
+ {/* Browser access - open the Control UI, approve and revoke browsers */}
1141
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1142
+ <SectionHeader icon={<KeyIcon size={12} />} label="BROWSER ACCESS" />
1143
+ <div style={{ padding: '12px 14px' }}>
1144
+ {agent.state === 'running' && agent.controlPort && (
1145
+ <div style={{ marginBottom: 8 }}>
1146
+ <OpenControlUiButton agentId={agent.agentId} label="Open Control UI ↗" />
1147
+ </div>
1148
+ )}
1149
+ <BrowserAccessSection agentId={agent.agentId} running={agent.state === 'running'} onChanged={onAccessChanged} />
1150
+ </div>
1151
+ </div>
1152
+
1153
+ {/* Model - primary + fallbacks (moved here from the Gateway page) */}
1154
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1155
+ <SectionHeader icon={<BracesIcon size={12} />} label="MODEL" />
1156
+ <ModelSection agentId={agent.agentId} onSaved={onModelSaved} agentBusy={busy} />
1157
+ </div>
858
1158
 
859
1159
  {/* Config - View openclaw.json */}
860
1160
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
@@ -873,30 +1173,16 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
873
1173
  <IntegrationSection agentName={agent.name} />
874
1174
 
875
1175
  {/* Restart */}
876
- <RestartSection agentName={agent.name} />
1176
+ <RestartSection agentName={agent.name} agentBusy={busy} />
877
1177
 
878
1178
  {/* Backup - persistent volume backup & restore */}
879
- <BackupSection agentName={agent.name} />
880
-
881
- {/* Access - agent Control UI URL (token inline in link) */}
882
- {controlUrl && (
883
- <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
884
- <SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
885
- <div style={{ padding: '7px 14px', fontSize: 12 }}>
886
- <div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
887
- <a href={controlUrl} target="_blank" rel="noopener noreferrer"
888
- style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
889
- >{controlUrl}</a>
890
-
891
- </div>
892
- </div>
893
- )}
1179
+ <BackupSection agentName={agent.name} agentBusy={busy} onStarted={(kind) => onActivity(agent.agentId, kind)} />
894
1180
 
895
1181
  {/* Config modal */}
896
1182
  {showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
897
1183
 
898
1184
  {/* Edit agent modal */}
899
- {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
1185
+ {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} onStarted={() => { setEditPing(Date.now()); onActivity(agent.agentId, 'edit'); }} />}
900
1186
 
901
1187
  {/* Delete confirmation */}
902
1188
  <ConfirmModal
@@ -911,15 +1197,14 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
911
1197
  />
912
1198
 
913
1199
  {/* Recreate */}
914
- {agent.imageOutdated && (
915
- <div style={{ marginBottom: 12, border: '1px solid rgba(234,179,8,0.3)', borderRadius: 0, padding: '10px 14px', background: 'rgba(234,179,8,0.06)' }}>
916
- <div style={{ fontSize: 11, color: 'var(--yellow)', marginBottom: 4, fontWeight: 500 }}>⚠ Container Outdated</div>
917
- <div style={{ fontSize: 11, color: '#888' }}>
918
- This container is running an older image. Recreate it to pick up the latest version.
919
- </div>
1200
+ {/* OpenClaw version and the Update action */}
1201
+ <div style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1202
+ <SectionHeader icon={<RotateIcon size={12} />} label="OPENCLAW VERSION" />
1203
+ <div style={{ padding: '12px 14px' }}>
1204
+ <UpdateSection agentId={agent.agentId} running={agent.state === 'running'} onChanged={onChanged} onStarted={() => onActivity(agent.agentId, 'update')} />
920
1205
  </div>
921
- )}
922
- <RecreateSection agentName={agent.name} onRecreated={onChanged} />
1206
+ </div>
1207
+ <RecreateSection agentName={agent.name} agentBusy={busy} onRecreated={onChanged} onStarted={() => onActivity(agent.agentId, 'recreate')} />
923
1208
 
924
1209
  {/* Delete */}
925
1210
  <div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
@@ -1015,98 +1300,221 @@ function IntegrationSection({ agentName }: { agentName: string }) {
1015
1300
  );
1016
1301
  }
1017
1302
 
1018
- /* ─── Backup section - persistent volume backup & restore ─── */
1303
+ /* ─── Backup section - persistent volume backup & restore (cold backup) ─── */
1019
1304
 
1020
- function BackupSection({ agentName }: { agentName: string }) {
1305
+ interface ColdBackupJob {
1306
+ status: 'running' | 'succeeded' | 'failed';
1307
+ file: string;
1308
+ kind?: string;
1309
+ percent: number | null;
1310
+ error: string | null;
1311
+ }
1312
+
1313
+ interface RestoreJob {
1314
+ status: 'restoring' | 'done' | 'failed' | 'interrupted';
1315
+ file: string | null;
1316
+ error: string | null;
1317
+ }
1318
+
1319
+ function BackupSection({ agentName, agentBusy, onStarted }: { agentName: string; agentBusy?: boolean; onStarted?: (kind: 'backup' | 'restore') => void }) {
1021
1320
  const [backups, setBackups] = useState<{ name: string; size: string; date: string }[]>([]);
1022
1321
  const [loading, setLoading] = useState(false);
1023
- const [status, setStatus] = useState<{ type: 'backup' | 'restore'; label: string } | null>(null);
1322
+ const [backup, setBackup] = useState<ColdBackupJob | null>(null);
1323
+ const [restore, setRestore] = useState<RestoreJob | null>(null);
1324
+ /** The one action in flight, and the file it is working on (keyed busy state). */
1325
+ const [busy, setBusy] = useState<{ kind: 'starting' | 'cancelling' | 'restoring' | 'deleting'; file?: string } | null>(null);
1024
1326
  const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
1025
1327
  const [restoreConfirm, setRestoreConfirm] = useState<string | null>(null);
1328
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
1329
+ const abortRef = useRef<AbortController | null>(null);
1026
1330
 
1027
1331
  const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
1028
1332
  setToast({ msg, type });
1029
1333
  setTimeout(() => setToast(null), 4000);
1030
1334
  };
1031
1335
 
1336
+ const stopPolling = useCallback(() => {
1337
+ if (pollRef.current) {
1338
+ clearInterval(pollRef.current);
1339
+ pollRef.current = null;
1340
+ }
1341
+ }, []);
1342
+
1032
1343
  const loadBackups = useCallback(async () => {
1344
+ abortRef.current?.abort();
1345
+ const controller = new AbortController();
1346
+ abortRef.current = controller;
1033
1347
  setLoading(true);
1034
1348
  try {
1035
- const res = await fetch(`/api/agents/${agentName}/backup`);
1349
+ const res = await fetch(`/api/agents/${agentName}/backup`, { signal: controller.signal });
1036
1350
  if (res.ok) {
1037
1351
  const data: { backups: { name: string; size: string; date: string }[] } = await res.json();
1038
1352
  setBackups(data.backups ?? []);
1039
1353
  }
1040
- } catch { /* ignore */ }
1354
+ } catch { /* aborted or transient — the list stays as it is */ }
1041
1355
  setLoading(false);
1042
1356
  }, [agentName]);
1043
1357
 
1044
- useEffect(() => { void loadBackups(); }, [loadBackups]);
1358
+ /* Poll a running job every 2s until it finishes; the job lives in Docker
1359
+ (a detached helper container), so this resumes cleanly after a reload. */
1360
+ const startPolling = useCallback(() => {
1361
+ stopPolling();
1362
+ const controller = new AbortController();
1363
+ abortRef.current = controller;
1364
+ pollRef.current = setInterval(async () => {
1365
+ try {
1366
+ const res = await fetch(`/api/agents/${agentName}/cold-backup`, { signal: controller.signal });
1367
+ if (!res.ok) return;
1368
+ const data = await res.json();
1369
+ const job: ColdBackupJob | null = data.backup ?? null;
1370
+ if (job?.status === 'running') {
1371
+ setBackup(job);
1372
+ return;
1373
+ }
1374
+ stopPolling();
1375
+ setBackup(null);
1376
+ if (job?.status === 'succeeded') showToast('Backup completed: ' + job.file);
1377
+ else if (job?.status === 'failed') showToast('Backup failed: ' + (job.error ?? 'unknown error'), 'error');
1378
+ await loadBackups();
1379
+ } catch { /* aborted or transient network error — the next tick retries */ }
1380
+ }, 2000);
1381
+ }, [agentName, loadBackups, stopPolling]);
1382
+
1383
+ /* Poll a running restore every 2s; the job is recorded server-side, so this resumes
1384
+ after a reload and a second restore cannot start meanwhile (the server answers 409). */
1385
+ const startRestorePolling = useCallback(() => {
1386
+ stopPolling();
1387
+ const controller = new AbortController();
1388
+ abortRef.current = controller;
1389
+ pollRef.current = setInterval(async () => {
1390
+ try {
1391
+ const res = await fetch(`/api/agents/${agentName}/restore`, { signal: controller.signal });
1392
+ if (!res.ok) return;
1393
+ const data = await res.json();
1394
+ const job: RestoreJob | null = data.restore ?? null;
1395
+ if (job?.status === 'restoring') {
1396
+ setRestore(job);
1397
+ return;
1398
+ }
1399
+ stopPolling();
1400
+ setRestore(null);
1401
+ if (job?.status === 'done') showToast('Restored from ' + (job.file ?? 'the backup'));
1402
+ else if (job?.status === 'failed') showToast('Restore failed: ' + (job.error ?? 'unknown error'), 'error');
1403
+ await loadBackups();
1404
+ } catch { /* aborted or transient network error — the next tick retries */ }
1405
+ }, 2000);
1406
+ }, [agentName, loadBackups, stopPolling]);
1407
+
1408
+ /* On mount: load the archive list and pick up a backup or restore already running. */
1409
+ useEffect(() => {
1410
+ let alive = true;
1411
+ void loadBackups();
1412
+ void (async () => {
1413
+ try {
1414
+ const res = await fetch(`/api/agents/${agentName}/cold-backup`);
1415
+ if (res.ok && alive) {
1416
+ const data = await res.json();
1417
+ if (data.backup?.status === 'running') {
1418
+ setBackup(data.backup);
1419
+ startPolling();
1420
+ return;
1421
+ }
1422
+ }
1423
+ const rRes = await fetch(`/api/agents/${agentName}/restore`);
1424
+ if (rRes.ok && alive) {
1425
+ const rData = await rRes.json();
1426
+ if (rData.restore?.status === 'restoring') {
1427
+ setRestore(rData.restore);
1428
+ startRestorePolling();
1429
+ }
1430
+ }
1431
+ } catch { /* ignore */ }
1432
+ })();
1433
+ return () => { alive = false; stopPolling(); };
1434
+ }, [agentName, startPolling, startRestorePolling, stopPolling, loadBackups]);
1045
1435
 
1046
1436
  const handleBackup = async () => {
1047
- setStatus({ type: 'backup', label: 'Creating backup...' });
1048
- setBackups([]);
1437
+ setBusy({ kind: 'starting' });
1049
1438
  try {
1050
- const res = await fetch(`/api/agents/${agentName}/backup`, { method: 'POST' });
1439
+ const res = await fetch(`/api/agents/${agentName}/cold-backup`, { method: 'POST' });
1051
1440
  const data = await res.json();
1052
- if (data.success) {
1053
- showToast('Backup created: ' + data.file);
1054
- await loadBackups();
1441
+ if (res.status === 202 && data.started) {
1442
+ setBackup({ status: 'running', file: data.file, percent: null, error: null });
1443
+ onStarted?.('backup');
1444
+ startPolling();
1055
1445
  } else {
1056
- showToast(data.error || 'Backup failed', 'error');
1446
+ showToast(data.error || 'Backup failed to start', 'error');
1057
1447
  }
1058
1448
  } catch (e: unknown) {
1059
1449
  showToast((e as Error).message, 'error');
1060
1450
  }
1061
- setStatus(null);
1451
+ setBusy(null);
1062
1452
  };
1063
1453
 
1064
- const handleRestore = async (file: string) => {
1065
- setRestoreConfirm(file);
1454
+ const handleCancelBackup = async () => {
1455
+ setBusy({ kind: 'cancelling' });
1456
+ try {
1457
+ const res = await fetch(`/api/agents/${agentName}/cold-backup`, { method: 'DELETE' });
1458
+ const data = await res.json().catch(() => ({}));
1459
+ if (!res.ok) {
1460
+ showToast(data.error || 'Cancel failed', 'error');
1461
+ } else {
1462
+ stopPolling();
1463
+ setBackup(null);
1464
+ showToast('Backup cancelled');
1465
+ await loadBackups();
1466
+ }
1467
+ } catch (e: unknown) {
1468
+ showToast((e as Error).message, 'error');
1469
+ }
1470
+ setBusy(null);
1066
1471
  };
1067
1472
 
1068
1473
  const executeRestore = async () => {
1069
1474
  const file = restoreConfirm;
1070
1475
  if (!file) return;
1071
1476
  setRestoreConfirm(null);
1072
- setStatus({ type: 'restore', label: `Restoring from ${file}... (container will restart)` });
1073
- setBackups([]);
1477
+ setBusy({ kind: 'restoring', file });
1074
1478
  try {
1075
1479
  const res = await fetch(`/api/agents/${agentName}/restore?file=${encodeURIComponent(file)}`, { method: 'POST' });
1076
1480
  const data = await res.json();
1077
- if (data.success) {
1078
- showToast('Restored from ' + file);
1079
- await loadBackups();
1481
+ if (res.status === 202 && data.started) {
1482
+ setRestore({ status: 'restoring', file, error: null });
1483
+ onStarted?.('restore');
1484
+ startRestorePolling();
1080
1485
  } else {
1081
- showToast(data.error || 'Restore failed', 'error');
1486
+ showToast(data.error || 'Restore failed to start', 'error');
1082
1487
  }
1083
1488
  } catch (e: unknown) {
1084
1489
  showToast((e as Error).message, 'error');
1085
1490
  }
1086
- setStatus(null);
1491
+ setBusy(null);
1087
1492
  };
1088
1493
 
1089
1494
  const handleDeleteBackup = async (file: string) => {
1090
- setStatus({ type: 'backup', label: `Deleting ${file}...` });
1495
+ setBusy({ kind: 'deleting', file });
1091
1496
  try {
1092
1497
  const res = await fetch(`/api/agents/${agentName}/backup?file=${encodeURIComponent(file)}`, { method: 'DELETE' });
1093
1498
  const data = await res.json().catch(() => ({}));
1094
1499
  if (!res.ok || !data.success) {
1095
1500
  showToast(data.error || 'Delete failed', 'error');
1096
1501
  }
1097
- setBackups([]);
1098
1502
  await loadBackups();
1099
1503
  } catch (e: unknown) {
1100
1504
  showToast((e as Error).message, 'error');
1101
1505
  }
1102
- setStatus(null);
1506
+ setBusy(null);
1103
1507
  };
1104
1508
 
1509
+ const running = backup?.status === 'running';
1510
+ const restoring = restore?.status === 'restoring';
1511
+ const actionsLocked = running || restoring || busy !== null || !!agentBusy;
1512
+
1105
1513
  return (
1106
1514
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1107
1515
  <SectionHeader icon={<ArchiveIcon size={12} />} label="BACKUP" />
1108
1516
  <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
1109
- {status && (
1517
+ {running && (
1110
1518
  <div style={{
1111
1519
  padding: '8px 12px',
1112
1520
  fontSize: 11,
@@ -1124,50 +1532,99 @@ function BackupSection({ agentName }: { agentName: string }) {
1124
1532
  borderRadius: '50%',
1125
1533
  background: 'var(--violet)',
1126
1534
  animation: 'pulse 1s infinite',
1535
+ flexShrink: 0,
1127
1536
  }} />
1128
- {status.label}
1537
+ <span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
1538
+ Backing up {backup?.file}
1539
+ {/* Inline and nowrap: the file name may break, but the percentage and the
1540
+ note move to the next line as one piece instead of splitting "5" / "3%". */}
1541
+ <span style={{ whiteSpace: 'nowrap' }}>
1542
+ {' '}{backup?.percent !== null && backup?.percent !== undefined ? `— ${backup.percent}% ` : ''}(the agent restarts when done)
1543
+ </span>
1544
+ </span>
1545
+ <span style={{ flexShrink: 0 }}>
1546
+ <Button
1547
+ variant="secondary"
1548
+ size="sm"
1549
+ loading={busy?.kind === 'cancelling'}
1550
+ disabled={busy !== null}
1551
+ onClick={handleCancelBackup}
1552
+ >
1553
+ {busy?.kind === 'cancelling' ? 'Cancelling…' : 'Cancel'}
1554
+ </Button>
1555
+ </span>
1129
1556
  </div>
1130
1557
  )}
1131
- {!status && (
1132
- <>
1133
- {loading && <div style={{ fontSize: 11, color: '#888' }}>Loading...</div>}
1134
- {!loading && backups.length === 0 && (
1135
- <div style={{ fontSize: 11, color: '#888' }}>No backups yet.</div>
1136
- )}
1137
- {backups.length > 0 && (
1138
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1139
- {backups.map((b) => (
1140
- <div key={b.name} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, fontFamily: 'var(--font-mono)', padding: '4px 0' }}>
1141
- <span style={{ flex: 1, color: 'var(--text)', wordBreak: 'break-all' }}>{b.name}</span>
1142
- <span style={{ color: '#888', whiteSpace: 'nowrap' }}>{b.size}</span>
1143
- <span style={{ color: '#666', whiteSpace: 'nowrap' }}>{b.date}</span>
1144
- <button
1145
- type="button"
1146
- disabled={!!status}
1147
- onClick={() => handleRestore(b.name)}
1148
- style={{ background: 'none', border: '1px solid var(--violet)', color: 'var(--violet)', cursor: 'pointer', fontSize: 10, padding: '2px 6px', opacity: status ? 0.5 : 1 }}
1149
- >
1150
- Restore
1151
- </button>
1152
- <button
1153
- type="button"
1154
- disabled={!!status}
1155
- onClick={() => handleDeleteBackup(b.name)}
1156
- style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12, padding: '2px', opacity: status ? 0.5 : 1 }}
1157
- >
1158
- ×
1159
- </button>
1160
- </div>
1161
- ))}
1558
+ {restoring && (
1559
+ <div style={{
1560
+ padding: '8px 12px',
1561
+ fontSize: 11,
1562
+ fontFamily: 'var(--font-mono)',
1563
+ color: 'var(--violet)',
1564
+ background: 'rgba(146,91,252,0.08)',
1565
+ border: '1px solid rgba(146,91,252,0.25)',
1566
+ display: 'flex',
1567
+ alignItems: 'center',
1568
+ gap: 8,
1569
+ }}>
1570
+ <span style={{
1571
+ display: 'inline-block',
1572
+ width: 8, height: 8,
1573
+ borderRadius: '50%',
1574
+ background: 'var(--violet)',
1575
+ animation: 'pulse 1s infinite',
1576
+ flexShrink: 0,
1577
+ }} />
1578
+ <span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
1579
+ Restoring {restore?.file}
1580
+ <span style={{ whiteSpace: 'nowrap' }}> (the agent restarts when done)</span>
1581
+ </span>
1582
+ </div>
1583
+ )}
1584
+ {loading && backups.length === 0 && <div style={{ fontSize: 11, color: '#888' }}>Loading...</div>}
1585
+ {!loading && backups.length === 0 && (
1586
+ <div style={{ fontSize: 11, color: '#888' }}>No backups yet.</div>
1587
+ )}
1588
+ {backups.length > 0 && (
1589
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1590
+ {backups.map((b) => (
1591
+ // Wraps instead of crushing the name: the name asks for a 220px basis and
1592
+ // grows; when the row can no longer fit size, date and the buttons beside
1593
+ // it, they move to a second line rather than squeezing the name to one
1594
+ // character per line.
1595
+ <div key={b.name} style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, fontSize: 11, fontFamily: 'var(--font-mono)', padding: '4px 0' }}>
1596
+ <span style={{ flex: '1 1 220px', minWidth: 0, color: 'var(--text)', wordBreak: 'break-all' }}>{b.name}</span>
1597
+ <span style={{ color: '#888', whiteSpace: 'nowrap', flexShrink: 0 }}>{b.size}</span>
1598
+ <span style={{ color: '#666', whiteSpace: 'nowrap', flexShrink: 0 }}>{b.date}</span>
1599
+ <span style={{ display: 'flex', gap: 8, flexShrink: 0, marginLeft: 'auto' }}>
1600
+ <Button
1601
+ variant="secondary"
1602
+ size="sm"
1603
+ disabled={actionsLocked}
1604
+ loading={busy?.kind === 'restoring' && busy.file === b.name}
1605
+ onClick={() => setRestoreConfirm(b.name)}
1606
+ >
1607
+ Restore
1608
+ </Button>
1609
+ <Button
1610
+ variant="danger"
1611
+ size="sm"
1612
+ disabled={actionsLocked}
1613
+ loading={busy?.kind === 'deleting' && busy.file === b.name}
1614
+ onClick={() => handleDeleteBackup(b.name)}
1615
+ >
1616
+ Delete
1617
+ </Button>
1618
+ </span>
1162
1619
  </div>
1163
- )}
1164
- <div style={{ display: 'flex', gap: 8 }}>
1165
- <Button variant="secondary" size="sm" disabled={!!status} onClick={handleBackup}>
1166
- Backup Now
1167
- </Button>
1168
- </div>
1169
- </>
1620
+ ))}
1621
+ </div>
1170
1622
  )}
1623
+ <div style={{ display: 'flex', gap: 8 }}>
1624
+ <Button variant="secondary" size="sm" disabled={actionsLocked} onClick={handleBackup}>
1625
+ {busy?.kind === 'starting' ? 'Starting…' : 'Backup Now'}
1626
+ </Button>
1627
+ </div>
1171
1628
  </div>
1172
1629
  {toast && (
1173
1630
  <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: toast.type === 'success' ? '#22c55e' : '#ef4444' }}>
@@ -1190,7 +1647,7 @@ function BackupSection({ agentName }: { agentName: string }) {
1190
1647
 
1191
1648
  /* ─── Restart section - simple container restart ─── */
1192
1649
 
1193
- function RestartSection({ agentName }: { agentName: string }) {
1650
+ function RestartSection({ agentName, agentBusy }: { agentName: string; agentBusy?: boolean }) {
1194
1651
  const [restarting, setRestarting] = useState(false);
1195
1652
  const [toast, setToast] = useState<string | null>(null);
1196
1653
 
@@ -1222,7 +1679,7 @@ function RestartSection({ agentName }: { agentName: string }) {
1222
1679
  <div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
1223
1680
  Restart the container without losing any data.
1224
1681
  </div>
1225
- <Button variant="secondary" size="sm" loading={restarting} disabled={restarting} onClick={handleRestart}>
1682
+ <Button variant="secondary" size="sm" loading={restarting} disabled={restarting || !!agentBusy} onClick={handleRestart}>
1226
1683
  {restarting ? 'Restarting...' : 'Restart Container'}
1227
1684
  </Button>
1228
1685
  </div>
@@ -1237,47 +1694,121 @@ function RestartSection({ agentName }: { agentName: string }) {
1237
1694
 
1238
1695
  /* ─── Recreate section - rebuild container from same image while keeping volume ─── */
1239
1696
 
1240
- function RecreateSection({ agentName, onRecreated }: {
1697
+ interface RecreateJob {
1698
+ status: 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
1699
+ image: string | null;
1700
+ backupFile: string | null;
1701
+ backupPercent: number | null;
1702
+ error: string | null;
1703
+ }
1704
+
1705
+ function RecreateSection({ agentName, agentBusy, onRecreated, onStarted }: {
1241
1706
  agentName: string;
1707
+ /** A long operation is running on this agent. */
1708
+ agentBusy?: boolean;
1242
1709
  /** The container was rebuilt — the parent refetches (new id, fresh image). */
1243
1710
  onRecreated: () => void;
1711
+ /** The recreate job started — show the RECREATING chip at once. */
1712
+ onStarted?: () => void;
1244
1713
  }) {
1245
- const [recreating, setRecreating] = useState(false);
1714
+ const [job, setJob] = useState<RecreateJob | null>(null);
1715
+ const [starting, setStarting] = useState(false);
1246
1716
  const [error, setError] = useState('');
1247
1717
  const [toast, setToast] = useState<string | null>(null);
1248
1718
  const [showConfirm, setShowConfirm] = useState(false);
1719
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
1720
+ const abortRef = useRef<AbortController | null>(null);
1721
+ const seenDone = useRef(false);
1249
1722
 
1250
1723
  const showToast = (msg: string) => {
1251
1724
  setToast(msg);
1252
1725
  setTimeout(() => setToast(null), 5000);
1253
1726
  };
1254
1727
 
1728
+ const stopPolling = useCallback(() => {
1729
+ if (pollRef.current) {
1730
+ clearInterval(pollRef.current);
1731
+ pollRef.current = null;
1732
+ }
1733
+ }, []);
1734
+
1735
+ /* Poll the job every 2s while it runs. The job is recorded in `agent_recreates`
1736
+ and the steps run in the Rev4a process, so a reload resumes from the row. */
1737
+ const startPolling = useCallback(() => {
1738
+ stopPolling();
1739
+ const controller = new AbortController();
1740
+ abortRef.current = controller;
1741
+ pollRef.current = setInterval(async () => {
1742
+ try {
1743
+ const res = await fetch(`/api/agents/${agentName}/recreate`, { signal: controller.signal });
1744
+ if (!res.ok) return;
1745
+ const data = await res.json();
1746
+ const current: RecreateJob | null = data.recreate ?? null;
1747
+ if (current && (current.status === 'backing_up' || current.status === 'recreating')) {
1748
+ setJob(current);
1749
+ return;
1750
+ }
1751
+ stopPolling();
1752
+ setJob(null);
1753
+ if (current?.status === 'done') {
1754
+ if (!seenDone.current) {
1755
+ seenDone.current = true;
1756
+ showToast('Container recreated successfully');
1757
+ onRecreated();
1758
+ }
1759
+ } else if (current?.status === 'failed' || current?.status === 'interrupted') {
1760
+ setError(current.error || 'Recreate failed');
1761
+ }
1762
+ } catch { /* aborted or transient network error — the next tick retries */ }
1763
+ }, 2000);
1764
+ }, [agentName, onRecreated, stopPolling]);
1765
+
1766
+ /* On mount: pick up a recreate that is already running. */
1767
+ useEffect(() => {
1768
+ let alive = true;
1769
+ void (async () => {
1770
+ try {
1771
+ const res = await fetch(`/api/agents/${agentName}/recreate`);
1772
+ if (res.ok && alive) {
1773
+ const data = await res.json();
1774
+ if (data.recreate && (data.recreate.status === 'backing_up' || data.recreate.status === 'recreating')) {
1775
+ setJob(data.recreate);
1776
+ startPolling();
1777
+ }
1778
+ }
1779
+ } catch { /* ignore */ }
1780
+ })();
1781
+ return () => { alive = false; stopPolling(); };
1782
+ }, [agentName, startPolling, stopPolling]);
1783
+
1255
1784
  const handleRecreate = async () => {
1256
1785
  setShowConfirm(false);
1257
- setRecreating(true);
1786
+ setStarting(true);
1258
1787
  setError('');
1259
1788
  try {
1260
1789
  const res = await fetch(`/api/agents/${agentName}/recreate`, { method: 'POST' });
1261
1790
  const data = await res.json();
1262
- if (data.success) {
1263
- showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
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();
1791
+ if (res.status === 202 && data.started) {
1792
+ seenDone.current = false;
1793
+ setJob({ status: 'backing_up', image: null, backupFile: null, backupPercent: null, error: null });
1794
+ onStarted?.();
1795
+ startPolling();
1267
1796
  } else {
1268
1797
  setError(data.error || 'Recreate failed');
1269
1798
  }
1270
1799
  } catch (e: unknown) {
1271
1800
  setError((e as Error).message || 'Network error');
1272
1801
  }
1273
- setRecreating(false);
1802
+ setStarting(false);
1274
1803
  };
1275
1804
 
1805
+ const running = job !== null;
1806
+
1276
1807
  return (
1277
1808
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1278
1809
  <SectionHeader icon={<PackageIcon size={12} />} label="RECREATE" />
1279
1810
  <div style={{ padding: '12px 14px' }}>
1280
- {recreating && (
1811
+ {running && (
1281
1812
  <div style={{
1282
1813
  padding: '8px 12px',
1283
1814
  fontSize: 11,
@@ -1296,15 +1827,25 @@ function RecreateSection({ agentName, onRecreated }: {
1296
1827
  borderRadius: '50%',
1297
1828
  background: 'var(--violet)',
1298
1829
  animation: 'pulse 1s infinite',
1830
+ flexShrink: 0,
1299
1831
  }} />
1300
- Recreating container...
1832
+ <span style={{ flex: 1, minWidth: 0, wordBreak: 'break-all' }}>
1833
+ {job.status === 'backing_up' ? (
1834
+ <>
1835
+ Backing up {job.backupFile ?? '…'}
1836
+ <span style={{ whiteSpace: 'nowrap' }}>
1837
+ {' '}{job.backupPercent !== null ? `— ${job.backupPercent}% ` : ''}(the agent restarts when done)
1838
+ </span>
1839
+ </>
1840
+ ) : 'Recreating container…'}
1841
+ </span>
1301
1842
  </div>
1302
1843
  )}
1303
1844
  <div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
1304
- Takes an automatic backup, then rebuilds the container from the latest image - keeping workspace, config, credentials, and state intact. Use this to pick up image/OpenClaw updates.
1845
+ Takes an automatic cold backup — the agent stops for it — then rebuilds the container on the same OpenClaw version, keeping workspace, config, credentials and state. To move to a newer OpenClaw version, use Update above.
1305
1846
  </div>
1306
- <Button variant="secondary" size="sm" loading={recreating} disabled={recreating} onClick={() => setShowConfirm(true)}>
1307
- {recreating ? 'Recreating...' : 'Recreate Container'}
1847
+ <Button variant="secondary" size="sm" disabled={running || starting || !!agentBusy} onClick={() => setShowConfirm(true)}>
1848
+ {starting ? 'Starting…' : running ? 'Recreating…' : 'Recreate Container'}
1308
1849
  </Button>
1309
1850
  {error && <div style={{ fontSize: 11, color: '#ef4444', marginTop: 6 }}>{error}</div>}
1310
1851
  </div>
@@ -1318,7 +1859,7 @@ function RecreateSection({ agentName, onRecreated }: {
1318
1859
  onClose={() => setShowConfirm(false)}
1319
1860
  onConfirm={handleRecreate}
1320
1861
  title="Recreate Container"
1321
- message={`Recreate container "${agentName}"?\n\nAn automatic backup is taken first, then the container is rebuilt from the latest image. Workspace, credentials, and state are preserved in the persistent volume. If the backup fails, the recreate is aborted.`}
1862
+ message={`Recreate container "${agentName}"?\n\nAn automatic cold backup is taken first — the agent stops for it — then the container is rebuilt on the same image. Workspace, credentials, and state are preserved in the persistent volume. If the backup fails, the recreate is aborted and the agent is started again.`}
1322
1863
  confirmLabel="Recreate"
1323
1864
  cancelLabel="Cancel"
1324
1865
  tone="warning"
@@ -1329,7 +1870,7 @@ function RecreateSection({ agentName, onRecreated }: {
1329
1870
 
1330
1871
  /* ─── Channel section - Telegram ─── */
1331
1872
 
1332
- function ChannelSection({ agentId, agentName }: { agentId: string; agentName: string }) {
1873
+ function ChannelSection({ agentId, displayName }: { agentId: string; displayName: string }) {
1333
1874
  const [open, setOpen] = useState(false);
1334
1875
  const [preview, setPreview] = useState<{ telegram: boolean } | null>(null);
1335
1876
  const previewAbortRef = useRef<AbortController | null>(null);
@@ -1381,7 +1922,7 @@ function ChannelSection({ agentId, agentName }: { agentId: string; agentName: st
1381
1922
  </div>
1382
1923
  <ChannelManager
1383
1924
  agentId={agentId}
1384
- agentName={agentName}
1925
+ displayName={displayName}
1385
1926
  open={open}
1386
1927
  onClose={() => { setOpen(false); doFetchPreview(); }}
1387
1928
  />