@swarmclawai/swarmclaw 1.8.11 → 1.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,13 +8,13 @@
8
8
  <img src="https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/public/branding/swarmclaw-org-avatar.png" alt="SwarmClaw lobster logo" width="120" />
9
9
  </p>
10
10
 
11
- <p align="center"><strong>Self-hosted runtime for autonomous AI agents.</strong> Multi-provider, MCP-native, with memory, skills, delegation, and schedules.</p>
11
+ <p align="center"><strong>The self-hosted AI agent runtime and multi-agent framework for autonomous agents.</strong> Open-source agent swarms with durable agent memory, MCP tools, skills, delegation, schedules, and 23+ LLM providers — a practical Claude Code and LangChain alternative.</p>
12
12
 
13
13
  <p align="center">
14
14
  <img src="doc/assets/screenshots/org-chart.png" alt="SwarmClaw org chart with delegation and live agent activity" width="900" />
15
15
  </p>
16
16
 
17
- SwarmClaw is a self-hosted AI runtime for OpenClaw and multi-agent work. It helps you run autonomous agents and orchestrators with heartbeats, schedules, delegation, memory, runtime skills, and reviewed conversation-to-skill learning across OpenClaw gateways and other providers.
17
+ SwarmClaw is an open-source, self-hosted AI agent runtime and multi-agent framework. Run autonomous AI agents, agent swarms, and orchestrators with heartbeats, schedules, delegation, agent memory, runtime skills, and reviewed conversation-to-skill learning across OpenClaw gateways, Claude, GPT, Gemini, OpenRouter, Ollama, and 23+ other providers. Use it as your AI agent dashboard, agent orchestration platform, and home base for self-hosted multi-agent AI workflows.
18
18
 
19
19
  GitHub: https://github.com/swarmclawai/swarmclaw
20
20
  Docs: https://swarmclaw.ai/docs
@@ -399,6 +399,22 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.8.13 Highlights
403
+
404
+ Task retry and host execute hotfix for issues [#68](https://github.com/swarmclawai/swarmclaw/issues/68) and [#69](https://github.com/swarmclawai/swarmclaw/issues/69).
405
+
406
+ - **Per-agent host execute.** Agents configured with `executeConfig.backend = "host"` now pass that setting into the runtime `execute` tool, so `persistent=true` uses the documented host backend.
407
+ - **Scheduled task validation.** Schedule-created tasks no longer get auto-classified as implementation tasks for quality gates unless they explicitly opt into a task quality gate.
408
+ - **Retry loop guard.** A task that fails again with the same retry reason is dead-lettered instead of spending another run on identical work.
409
+
410
+ ### v1.8.12 Highlights
411
+
412
+ Gateway Fleet Command release: SwarmClaw now treats OpenClaw gateways as an operator surface instead of a background provider detail.
413
+
414
+ - **Fleet topology API.** Added gateway topology endpoints that collect OpenClaw nodes, node pairings, device pairings, sessions, presence, and best-effort RPC errors in one server-side snapshot.
415
+ - **Provider console controls.** The Providers screen can refresh a whole gateway fleet or a single gateway topology, showing sessions, presence, pending pairings, and topology warnings alongside deploy and runtime health.
416
+ - **Operations Pulse coverage.** Degraded gateways, stale topology, failed topology refreshes, and pending OpenClaw pairings now appear in the shared operator triage queue.
417
+
402
418
  ### v1.8.11 Highlights
403
419
 
404
420
  DeepSeek tool-use hotfix for issue [#67](https://github.com/swarmclawai/swarmclaw/issues/67).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.8.11",
3
+ "version": "1.8.13",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -86,8 +86,8 @@
86
86
  "cli": "node ./bin/swarmclaw.js",
87
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
- "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
89
+ "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/session-tools/execute.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { notFound } from '@/lib/server/collection-helpers'
4
+ import { getOpenClawGatewayTopology } from '@/lib/server/gateways/gateway-topology'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params
10
+ const topology = await getOpenClawGatewayTopology(id)
11
+ if (!topology) return notFound()
12
+ return NextResponse.json(topology)
13
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { getOpenClawGatewayFleetTopology } from '@/lib/server/gateways/gateway-topology'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET() {
8
+ return NextResponse.json(await getOpenClawGatewayFleetTopology())
9
+ }
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('gateway topology route returns 404 for unknown profiles', () => {
7
+ const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
8
+ const routeMod = await import('./src/app/api/gateways/[id]/topology/route')
9
+ const route = routeMod.default || routeMod
10
+ const response = await route.GET(
11
+ new Request('http://local/api/gateways/missing/topology'),
12
+ { params: Promise.resolve({ id: 'missing' }) },
13
+ )
14
+ console.log(JSON.stringify({ status: response.status, body: await response.json() }))
15
+ `, { prefix: 'swarmclaw-gateway-topology-route-test-' })
16
+
17
+ assert.equal(output.status, 404)
18
+ assert.equal(output.body.error, 'Not found')
19
+ })
20
+
21
+ test('gateway fleet route reports empty totals when no OpenClaw profiles exist', () => {
22
+ const output = runWithTempDataDir<{
23
+ status: number
24
+ body: { gateways: unknown[]; totals: { gatewayCount: number; nodeCount: number; hasErrors: boolean } }
25
+ }>(`
26
+ const routeMod = await import('./src/app/api/gateways/fleet/route')
27
+ const route = routeMod.default || routeMod
28
+ const response = await route.GET(new Request('http://local/api/gateways/fleet'))
29
+ console.log(JSON.stringify({ status: response.status, body: await response.json() }))
30
+ `, { prefix: 'swarmclaw-gateway-fleet-route-test-' })
31
+
32
+ assert.equal(output.status, 200)
33
+ assert.equal(output.body.gateways.length, 0)
34
+ assert.equal(output.body.totals.gatewayCount, 0)
35
+ assert.equal(output.body.totals.nodeCount, 0)
36
+ assert.equal(output.body.totals.hasErrors, false)
37
+ })
package/src/cli/index.js CHANGED
@@ -272,6 +272,8 @@ const COMMAND_GROUPS = [
272
272
  cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
273
273
  cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
274
274
  cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
275
+ cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
276
+ cmd('fleet', 'GET', '/gateways/fleet', 'Refresh and return fleet-wide gateway topology'),
275
277
  ],
276
278
  },
277
279
  {
package/src/cli/spec.js CHANGED
@@ -220,6 +220,8 @@ const COMMAND_GROUPS = {
220
220
  update: { description: 'Update a gateway profile', method: 'PUT', path: '/gateways/:id', params: ['id'] },
221
221
  delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
222
222
  health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
223
+ topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
224
+ fleet: { description: 'Refresh and return fleet-wide gateway topology', method: 'GET', path: '/gateways/fleet' },
223
225
  },
224
226
  },
225
227
  logs: {
@@ -7,6 +7,9 @@ import { useAppStore } from '@/stores/use-app-store'
7
7
  import { toast } from 'sonner'
8
8
  import type {
9
9
  OpenClawDevicePairRequest,
10
+ OpenClawGatewayPresenceEntry,
11
+ OpenClawGatewayRpcError,
12
+ OpenClawGatewaySession,
10
13
  OpenClawNode,
11
14
  OpenClawNodePairRequest,
12
15
  OpenClawPairedDevice,
@@ -76,6 +79,9 @@ export function GatewaySheet() {
76
79
  const [nodePairings, setNodePairings] = useState<OpenClawNodePairRequest[]>([])
77
80
  const [devicePairings, setDevicePairings] = useState<OpenClawDevicePairRequest[]>([])
78
81
  const [pairedDevices, setPairedDevices] = useState<OpenClawPairedDevice[]>([])
82
+ const [gatewaySessions, setGatewaySessions] = useState<OpenClawGatewaySession[]>([])
83
+ const [gatewayPresence, setGatewayPresence] = useState<OpenClawGatewayPresenceEntry[]>([])
84
+ const [gatewayTopologyErrors, setGatewayTopologyErrors] = useState<OpenClawGatewayRpcError[]>([])
79
85
  const [invokeNodeId, setInvokeNodeId] = useState('')
80
86
  const [invokeCommand, setInvokeCommand] = useState('')
81
87
  const [invokeParamsText, setInvokeParamsText] = useState('{}')
@@ -113,6 +119,9 @@ export function GatewaySheet() {
113
119
  setNodePairings([])
114
120
  setDevicePairings([])
115
121
  setPairedDevices([])
122
+ setGatewaySessions([])
123
+ setGatewayPresence([])
124
+ setGatewayTopologyErrors([])
116
125
  setInvokeNodeId('')
117
126
  setInvokeCommand('')
118
127
  setInvokeParamsText('{}')
@@ -130,6 +139,9 @@ export function GatewaySheet() {
130
139
  setNodePairings(result.nodePairings)
131
140
  setDevicePairings(result.devicePairings)
132
141
  setPairedDevices(result.pairedDevices)
142
+ setGatewaySessions(result.sessions)
143
+ setGatewayPresence(result.presence)
144
+ setGatewayTopologyErrors(result.errors)
133
145
  if (result.nodes[0]) {
134
146
  setInvokeNodeId((current) => current || result.nodes[0].nodeId)
135
147
  setInvokeCommand((current) => current || result.nodes[0].commands?.[0] || '')
@@ -532,6 +544,37 @@ export function GatewaySheet() {
532
544
  </div>
533
545
  )}
534
546
 
547
+ <div className="mb-4 grid grid-cols-2 gap-2 md:grid-cols-4">
548
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
549
+ <div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Nodes</div>
550
+ <div className="mt-1 font-display text-[18px] font-700 text-text">{nodes.filter((node) => node.connected).length}/{nodes.length}</div>
551
+ </div>
552
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
553
+ <div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Pairings</div>
554
+ <div className="mt-1 font-display text-[18px] font-700 text-amber-300">{nodePairings.length + devicePairings.length}</div>
555
+ </div>
556
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
557
+ <div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Sessions</div>
558
+ <div className="mt-1 font-display text-[18px] font-700 text-text">{gatewaySessions.length}</div>
559
+ </div>
560
+ <div className={`rounded-[12px] border px-3 py-2 ${
561
+ gatewayTopologyErrors.length > 0
562
+ ? 'border-rose-400/20 bg-rose-400/[0.06]'
563
+ : 'border-white/[0.06] bg-white/[0.025]'
564
+ }`}>
565
+ <div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Presence</div>
566
+ <div className={gatewayTopologyErrors.length > 0 ? 'mt-1 font-display text-[18px] font-700 text-rose-200' : 'mt-1 font-display text-[18px] font-700 text-text'}>
567
+ {gatewayPresence.length}
568
+ </div>
569
+ </div>
570
+ </div>
571
+
572
+ {gatewayTopologyErrors.length > 0 && (
573
+ <div className="mb-4 rounded-[12px] border border-rose-400/20 bg-rose-400/[0.06] px-3 py-2 text-[12px] text-rose-200">
574
+ {gatewayTopologyErrors[0]?.method}: {gatewayTopologyErrors[0]?.message}
575
+ </div>
576
+ )}
577
+
535
578
  <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
536
579
  <div className="space-y-4">
537
580
  <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
- import { AlertTriangle, CheckCircle2, Clock, PlugZap, RefreshCw } from 'lucide-react'
5
+ import { AlertTriangle, CheckCircle2, Clock, PlugZap, RadioTower, RefreshCw } from 'lucide-react'
6
6
  import { api } from '@/lib/app/api-client'
7
7
  import { cn } from '@/lib/utils'
8
8
  import type { OperationPulse, OperationPulseAction, OperationPulseRange, OperationPulseSeverity } from '@/types'
@@ -39,6 +39,7 @@ function Kpi({ label, value, danger = false }: { label: string; value: number; d
39
39
  function actionIcon(action: OperationPulseAction) {
40
40
  if (action.severity === 'high') return <AlertTriangle size={15} />
41
41
  if (action.kind === 'connector') return <PlugZap size={15} />
42
+ if (action.kind === 'gateway') return <RadioTower size={15} />
42
43
  if (action.kind === 'mission') return <Clock size={15} />
43
44
  return <CheckCircle2 size={15} />
44
45
  }
@@ -82,6 +83,7 @@ export function OperationsPulsePanel({
82
83
  return pulse.kpis.failedRuns === 0
83
84
  && pulse.kpis.pendingApprovals === 0
84
85
  && pulse.kpis.connectorAttention === 0
86
+ && pulse.kpis.gatewayAttention === 0
85
87
  && pulse.kpis.budgetWarnings === 0
86
88
  }, [pulse])
87
89
 
@@ -92,7 +94,7 @@ export function OperationsPulsePanel({
92
94
  <div className="text-[10px] font-700 uppercase tracking-[0.16em] text-accent-bright/70">Operations Pulse</div>
93
95
  <h2 className="mt-1 font-display text-[16px] font-700 tracking-normal text-text">What needs operator attention next</h2>
94
96
  <p className="mt-1 max-w-[680px] text-[12px] leading-relaxed text-text-3/68">
95
- Missions, runs, approvals, connector readiness, and budget pressure rolled into one triage queue.
97
+ Missions, runs, approvals, connector readiness, OpenClaw gateways, and budget pressure rolled into one triage queue.
96
98
  </p>
97
99
  </div>
98
100
  <div className="flex flex-wrap items-center gap-2">
@@ -130,12 +132,13 @@ export function OperationsPulsePanel({
130
132
  </div>
131
133
  ) : (
132
134
  <>
133
- <div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-2 md:grid-cols-3 xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6')}>
135
+ <div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-2 md:grid-cols-4 xl:grid-cols-7' : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-7')}>
134
136
  <Kpi label="Missions" value={pulse.kpis.activeMissions} />
135
137
  <Kpi label="Running" value={pulse.kpis.runningRuns} />
136
138
  <Kpi label="Failed" value={pulse.kpis.failedRuns} danger />
137
139
  <Kpi label="Approvals" value={pulse.kpis.pendingApprovals} />
138
140
  <Kpi label="Connectors" value={pulse.kpis.connectorAttention} danger />
141
+ <Kpi label="Gateways" value={pulse.kpis.gatewayAttention} danger />
139
142
  <Kpi label="Budgets" value={pulse.kpis.budgetWarnings} />
140
143
  </div>
141
144
 
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useMemo, useState } from 'react'
4
4
  import { toast } from 'sonner'
5
5
  import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
6
6
  import { useAppStore } from '@/stores/use-app-store'
@@ -14,8 +14,10 @@ import { useCredentialsQuery, useCreateCredentialMutation } from '@/features/cre
14
14
  import {
15
15
  useCloneGatewayProfileMutation,
16
16
  useDeleteGatewayProfileMutation,
17
+ useGatewayFleetTopologyQuery,
17
18
  useGatewayHealthCheckMutation,
18
19
  useGatewayProfilesQuery,
20
+ useRefreshGatewayTopologyMutation,
19
21
  useSaveGatewayProfileMutation,
20
22
  useVerifyOpenClawDeployMutation,
21
23
  } from '@/features/gateways/queries'
@@ -52,6 +54,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
52
54
  const providersQuery = useProvidersQuery()
53
55
  const providerConfigsQuery = useProviderConfigsQuery()
54
56
  const gatewayProfilesQuery = useGatewayProfilesQuery()
57
+ const gatewayFleetTopologyQuery = useGatewayFleetTopologyQuery({ enabled: false })
55
58
  const externalAgentsQuery = useExternalAgentsQuery()
56
59
  const credentialsQuery = useCredentialsQuery()
57
60
  const toggleProviderMutation = useToggleProviderMutation()
@@ -60,6 +63,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
60
63
  const saveGatewayMutation = useSaveGatewayProfileMutation()
61
64
  const deleteGatewayMutation = useDeleteGatewayProfileMutation()
62
65
  const healthCheckGatewayMutation = useGatewayHealthCheckMutation()
66
+ const refreshGatewayTopologyMutation = useRefreshGatewayTopologyMutation()
63
67
  const verifyDeployMutation = useVerifyOpenClawDeployMutation()
64
68
  const cloneGatewayMutation = useCloneGatewayProfileMutation()
65
69
  const runtimeActionMutation = useExternalAgentRuntimeMutation()
@@ -67,6 +71,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
67
71
  const providers = providersQuery.data ?? []
68
72
  const providerConfigs = providerConfigsQuery.data ?? []
69
73
  const gatewayProfiles = gatewayProfilesQuery.data ?? []
74
+ const gatewayFleetTopology = gatewayFleetTopologyQuery.data ?? null
70
75
  const externalAgents = externalAgentsQuery.data ?? []
71
76
  const credentials = credentialsQuery.data ?? {}
72
77
  const savingDeploy = createCredentialMutation.isPending
@@ -103,6 +108,31 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
103
108
  await healthCheckGatewayMutation.mutateAsync(id)
104
109
  }
105
110
 
111
+ const handleRefreshGatewayTopology = async (e: React.MouseEvent, id: string) => {
112
+ e.stopPropagation()
113
+ try {
114
+ const result = await refreshGatewayTopologyMutation.mutateAsync(id)
115
+ if (result.errors.length > 0) {
116
+ toast.warning(`Topology refreshed with ${result.errors.length} warning${result.errors.length === 1 ? '' : 's'}`)
117
+ } else {
118
+ toast.success('Gateway topology refreshed')
119
+ }
120
+ } catch (err: unknown) {
121
+ toast.error(err instanceof Error ? err.message : 'Failed to refresh gateway topology')
122
+ }
123
+ }
124
+
125
+ const handleRefreshFleetTopology = async () => {
126
+ const result = await gatewayFleetTopologyQuery.refetch()
127
+ if (result.error) {
128
+ toast.error(result.error instanceof Error ? result.error.message : 'Failed to refresh fleet topology')
129
+ return
130
+ }
131
+ const errors = result.data?.totals.lastTopologyErrorCount || 0
132
+ if (errors > 0) toast.warning(`Fleet topology refreshed with ${errors} warning${errors === 1 ? '' : 's'}`)
133
+ else toast.success('Fleet topology refreshed')
134
+ }
135
+
106
136
  const handleDeployApply = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => {
107
137
  if (!patch.endpoint) return
108
138
  setDeployDraft({
@@ -246,6 +276,9 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
246
276
  const enabledItems = allItems.filter((item) => item.isEnabled)
247
277
  const disabledItems = allItems.filter((item) => !item.isEnabled)
248
278
  const gatewayNameById = new Map(gatewayProfiles.map((gateway) => [gateway.id, gateway.name]))
279
+ const topologyByGatewayId = useMemo(() => new Map(
280
+ (gatewayFleetTopology?.gateways || []).map((topology) => [topology.profile.id, topology]),
281
+ ), [gatewayFleetTopology])
249
282
  const runtimeHealthByGateway = externalAgents.reduce<Record<string, { total: number; active: number; lastHeartbeatAt: number | null }>>((acc, runtime) => {
250
283
  if (!runtime.gatewayProfileId) return acc
251
284
  const current = acc[runtime.gatewayProfileId] || { total: 0, active: 0, lastHeartbeatAt: null }
@@ -417,6 +450,14 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
417
450
  <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>
418
451
  {!inSidebar && (
419
452
  <div className="flex items-center gap-2">
453
+ <button
454
+ type="button"
455
+ onClick={() => void handleRefreshFleetTopology()}
456
+ disabled={gatewayFleetTopologyQuery.isFetching}
457
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer disabled:opacity-40"
458
+ >
459
+ {gatewayFleetTopologyQuery.isFetching ? 'Refreshing…' : 'Refresh Fleet'}
460
+ </button>
420
461
  <button
421
462
  type="button"
422
463
  onClick={() => handleEditGateway(null)}
@@ -427,6 +468,34 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
427
468
  </div>
428
469
  )}
429
470
  </div>
471
+ {!inSidebar && gatewayFleetTopology && (
472
+ <div className="mb-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3">
473
+ <div className="flex flex-wrap items-center justify-between gap-3">
474
+ <div>
475
+ <div className="text-[12px] font-800 text-text">Gateway fleet topology</div>
476
+ <div className="mt-1 text-[11px] text-text-3/70">
477
+ {gatewayFleetTopology.totals.connectedGatewayCount}/{gatewayFleetTopology.totals.gatewayCount} gateways connected ·{' '}
478
+ {gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected
479
+ </div>
480
+ </div>
481
+ <div className="flex flex-wrap gap-2 text-[11px] text-text-3/70">
482
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
483
+ {gatewayFleetTopology.totals.sessionCount || 0} sessions
484
+ </span>
485
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
486
+ {gatewayFleetTopology.totals.presenceCount || 0} presence
487
+ </span>
488
+ <span className={`rounded-full border px-2.5 py-1 ${
489
+ gatewayFleetTopology.totals.pendingPairingCount > 0
490
+ ? 'border-amber-400/20 bg-amber-400/[0.06] text-amber-300'
491
+ : 'border-white/[0.06] bg-white/[0.03]'
492
+ }`}>
493
+ {gatewayFleetTopology.totals.pendingPairingCount || 0} pending pairings
494
+ </span>
495
+ </div>
496
+ </div>
497
+ </div>
498
+ )}
430
499
  {!inSidebar && (
431
500
  <div className="mb-4 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
432
501
  <OpenClawDeployPanel
@@ -462,7 +531,11 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
462
531
  (() => {
463
532
  const runtimeStats = runtimeHealthByGateway[gateway.id] || { total: 0, active: 0, lastHeartbeatAt: null }
464
533
  const deployment = gateway.deployment || null
465
- const stats = gateway.stats || null
534
+ const topology = topologyByGatewayId.get(gateway.id) || null
535
+ const stats = topology?.stats || gateway.stats || null
536
+ const topologyErrors = topology?.errors || []
537
+ const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
538
+ const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
466
539
  return (
467
540
  <div
468
541
  key={gateway.id}
@@ -529,6 +602,12 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
529
602
  {stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
530
603
  </div>
531
604
  </div>
605
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
606
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Sessions</div>
607
+ <div className="mt-1 text-text-2">
608
+ {stats?.sessionCount ?? 0} sessions · {stats?.presenceCount ?? 0} presence
609
+ </div>
610
+ </div>
532
611
  <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
533
612
  <div className="uppercase tracking-[0.08em] text-text-3/50">Runtimes</div>
534
613
  <div className="mt-1 text-text-2">
@@ -537,6 +616,17 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
537
616
  </div>
538
617
  </div>
539
618
  )}
619
+ {!inSidebar && (pendingPairings > 0 || topologyErrorCount > 0) && (
620
+ <div className={`mt-3 rounded-[10px] border px-3 py-2 text-[11px] leading-relaxed ${
621
+ topologyErrorCount > 0
622
+ ? 'border-rose-400/20 bg-rose-400/[0.06] text-rose-200'
623
+ : 'border-amber-400/20 bg-amber-400/[0.06] text-amber-200'
624
+ }`}>
625
+ {topologyErrorCount > 0
626
+ ? `Topology warning: ${topologyErrors[0]?.message || stats?.lastTopologyError || `${topologyErrorCount} refresh errors`}`
627
+ : `${pendingPairings} pending OpenClaw pairing request${pendingPairings === 1 ? '' : 's'}`}
628
+ </div>
629
+ )}
540
630
  {!inSidebar && deployment?.lastVerifiedMessage && (
541
631
  <div className="mt-3 text-[11px] text-text-3/60">
542
632
  {deployment.lastVerifiedMessage}
@@ -547,6 +637,13 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
547
637
  <button onClick={(e) => void handleHealthCheckGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
548
638
  Health
549
639
  </button>
640
+ <button
641
+ onClick={(e) => void handleRefreshGatewayTopology(e, gateway.id)}
642
+ disabled={refreshGatewayTopologyMutation.isPending}
643
+ className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all disabled:opacity-40"
644
+ >
645
+ Topology
646
+ </button>
550
647
  <button onClick={(e) => void handleCloneGateway(e, gateway)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
551
648
  Clone
552
649
  </button>
@@ -4,6 +4,11 @@ import { credentialQueryKeys } from '@/features/credentials/queries'
4
4
  import type {
5
5
  GatewayProfile,
6
6
  OpenClawDevicePairRequest,
7
+ OpenClawGatewayFleetTopology,
8
+ OpenClawGatewayPresenceEntry,
9
+ OpenClawGatewayRpcError,
10
+ OpenClawGatewaySession,
11
+ OpenClawGatewayTopology,
7
12
  OpenClawNode,
8
13
  OpenClawNodePairRequest,
9
14
  OpenClawPairedDevice,
@@ -27,15 +32,6 @@ export interface GatewayRpcResponse<T> {
27
32
  error?: string
28
33
  }
29
34
 
30
- interface NodeListResult {
31
- nodes?: OpenClawNode[]
32
- }
33
-
34
- interface PairingListResult<T> {
35
- pending?: T[]
36
- paired?: OpenClawPairedDevice[]
37
- }
38
-
39
35
  interface SaveGatewayProfileInput {
40
36
  id?: string | null
41
37
  payload: Record<string, unknown>
@@ -62,6 +58,10 @@ export interface RefreshGatewayTopologyResult {
62
58
  nodePairings: OpenClawNodePairRequest[]
63
59
  devicePairings: OpenClawDevicePairRequest[]
64
60
  pairedDevices: OpenClawPairedDevice[]
61
+ sessions: OpenClawGatewaySession[]
62
+ presence: OpenClawGatewayPresenceEntry[]
63
+ errors: OpenClawGatewayRpcError[]
64
+ topology: OpenClawGatewayTopology
65
65
  }
66
66
 
67
67
  async function invalidateGatewayQueries(queryClient: ReturnType<typeof useQueryClient>) {
@@ -71,6 +71,8 @@ async function invalidateGatewayQueries(queryClient: ReturnType<typeof useQueryC
71
71
  export const gatewayQueryKeys = {
72
72
  all: ['gateways'] as const,
73
73
  profiles: () => ['gateways', 'profiles'] as const,
74
+ fleet: () => ['gateways', 'fleet'] as const,
75
+ topology: (id: string) => ['gateways', 'topology', id] as const,
74
76
  }
75
77
 
76
78
  export function useGatewayProfilesQuery(options: QueryOptions = {}) {
@@ -126,6 +128,15 @@ export function useGatewayHealthCheckMutation() {
126
128
  })
127
129
  }
128
130
 
131
+ export function useGatewayFleetTopologyQuery(options: QueryOptions = {}) {
132
+ return useQuery<OpenClawGatewayFleetTopology>({
133
+ queryKey: gatewayQueryKeys.fleet(),
134
+ queryFn: () => api<OpenClawGatewayFleetTopology>('GET', '/gateways/fleet'),
135
+ enabled: options.enabled,
136
+ staleTime: 30_000,
137
+ })
138
+ }
139
+
129
140
  export function useVerifyOpenClawDeployMutation() {
130
141
  return useMutation<VerifyOpenClawDeployResult, Error, VerifyOpenClawDeployInput>({
131
142
  mutationFn: ({ endpoint, token }) =>
@@ -170,48 +181,24 @@ export function useRefreshGatewayTopologyMutation() {
170
181
  const queryClient = useQueryClient()
171
182
  return useMutation<RefreshGatewayTopologyResult, Error, string>({
172
183
  mutationFn: async (profileId) => {
173
- const [nodesRes, nodePairRes, devicePairRes] = await Promise.all([
174
- api<GatewayRpcResponse<NodeListResult>>('POST', '/openclaw/gateway', {
175
- method: 'node.list',
176
- params: { profileId },
177
- }),
178
- api<GatewayRpcResponse<PairingListResult<OpenClawNodePairRequest>>>('POST', '/openclaw/gateway', {
179
- method: 'node.pair.list',
180
- params: { profileId },
181
- }),
182
- api<GatewayRpcResponse<PairingListResult<OpenClawDevicePairRequest>>>('POST', '/openclaw/gateway', {
183
- method: 'device.pair.list',
184
- params: { profileId },
185
- }),
186
- ])
187
-
188
- if (nodesRes.error) throw new Error(nodesRes.error)
189
- if (nodePairRes.error) throw new Error(nodePairRes.error)
190
- if (devicePairRes.error) throw new Error(devicePairRes.error)
191
-
192
- const nodes = Array.isArray(nodesRes.result?.nodes) ? nodesRes.result.nodes : []
193
- const nodePairings = Array.isArray(nodePairRes.result?.pending) ? nodePairRes.result.pending : []
194
- const devicePairings = Array.isArray(devicePairRes.result?.pending) ? devicePairRes.result.pending : []
195
- const pairedDevices = Array.isArray(devicePairRes.result?.paired) ? devicePairRes.result.paired : []
196
- const stats = {
197
- nodeCount: nodes.length,
198
- connectedNodeCount: nodes.filter((node) => node.connected).length,
199
- pendingNodePairings: nodePairings.length,
200
- pairedDeviceCount: pairedDevices.length,
201
- pendingDevicePairings: devicePairings.length,
202
- }
203
-
204
- void api('PUT', `/gateways/${profileId}`, { stats }).catch(() => {})
184
+ const topology = await api<OpenClawGatewayTopology>('GET', `/gateways/${profileId}/topology`)
205
185
 
206
186
  return {
207
- nodes,
208
- nodePairings,
209
- devicePairings,
210
- pairedDevices,
187
+ nodes: topology.nodes,
188
+ nodePairings: topology.nodePairings,
189
+ devicePairings: topology.devicePairings,
190
+ pairedDevices: topology.pairedDevices,
191
+ sessions: topology.sessions,
192
+ presence: topology.presence,
193
+ errors: topology.errors,
194
+ topology,
211
195
  }
212
196
  },
213
- onSuccess: async () => {
214
- await invalidateGatewayQueries(queryClient)
197
+ onSuccess: async (_result, profileId) => {
198
+ await Promise.all([
199
+ invalidateGatewayQueries(queryClient),
200
+ queryClient.invalidateQueries({ queryKey: gatewayQueryKeys.topology(profileId) }),
201
+ ])
215
202
  },
216
203
  })
217
204
  }
@@ -68,6 +68,11 @@ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
68
68
  pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
69
69
  pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
70
70
  externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
71
+ sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
72
+ presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
73
+ lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
74
+ lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
75
+ lastTopologyError: normalizeText(stats.lastTopologyError),
71
76
  }
72
77
  }
73
78